Commit Graph
1159 Commits
Author SHA1 Message Date
bvandeusenandClaude Fable 5 2fce57847b feat(snippets): pull-time freshness — the forge confirms the cache at the moment it's trusted (#2690)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Failing after 43s
CI & Build / Build & push image (push) Skipped
First consumer of the forge adapter. attach_live_body decorates both
pull surfaces (MCP get_snippet, REST detail) with body_source +
body_freshness when the instance has a forge: 'current' means the
cached code was just found verbatim (whitespace-normalized, the same
normalization the verdict hash uses) in the fetched file, and
provenance restamps to the file's last commit — reflected in the
response and persisted in the background. A snippet body is a FRAGMENT
of its file, so a fetch can honestly CONFIRM the cache or report
divergence, never clobber the record with the whole file: 'diverged'
is the reader's information, and a 404 stamps the mechanically-true
'missing' verdict into the existing attention state — once, not on
every pull of an already-flagged record.

The probe never raises and never blocks past 2.5s (tighter than the
adapter's own timeout — the pull is where a session decides whether
pulling is worth it, #2663's finding); a hung forge costs bounded time
and the cache serves. A no-forge instance's response stays
byte-identical to today's (rule #115 baseline, pinned by test).

services/background.py is the new one home for fire-and-forget tasks
with strong references (the #2663 GC footgun) — telemetry's two copies
predate it and keep their bespoke canaries; new callers use this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:46:45 -04:00
bvandeusenandClaude Fable 5 13e428c596 feat(forge): adapter seam + Gitea implementation — optional read access to the operator's forge (#2689)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 36s
Step 4 of milestone 288 (decision #2686). services/forge.py defines the
contract steps 5-7 consume — read_file (content + last_commit_sha, the
provenance stamp), default_branch, resolve_repo, check — with GiteaForge
as the first implementation over the REST contents/repo/version/user
endpoints. Repo identity reuses normalize_repo_key: the host segment
selects whether this forge serves a recorded repo, the remainder is the
API path, so no new identity scheme exists. Read-only by construction;
errors never carry the token; first outbound-HTTP timeout convention
(5s total, no retries — the consumer's fallback is the retry policy).

OPTIONAL per instance (rule #115): get_forge() returns None when
unconfigured and every consumer treats None as today's behavior. Config
lives in admin settings (Settings → Config → Git Forge: kind/base
URL/token, save + test-connection probe reporting version + identity),
with FORGE_* env / Docker-secret fallbacks; DB wins so a UI edit can't
silently lose to an env var. Token treatment follows the smtp_password
convention (masked on read, mask-sentinel skipped on write, absent from
audit details) — and wiring it surfaced that the generic GET/PUT
/api/settings dump bypassed that masking for the owning admin's raw KV
rows, so secret keys are now masked there too (fixes the same exposure
for smtp_password).

Contract tests run against httpx.MockTransport as the fake forge — the
reference behaviors the GitHub adapter (step 8) must reproduce — plus
the off-by-default gate, partial-config-is-off, env-vs-DB precedence,
and route/mask structural checks. Also: the step-2 definition detector
learned to skip dunders after flagging __init__ as 'already defined in
4 files' on this step's own build — guaranteed noise for a hint that
must stay trustworthy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:37:27 -04:00
bvandeusenandClaude Fable 5 7d26a3fc6a fix(tests): provenance itest user fixture is get-or-create — the lane DB persists across tests
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python tests (push) Successful in 53s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Build & push image (push) Successful in 19s
Both integration tests built the same username; the second insert died
on users_username_key. 19 passed, 1 error on run 3802.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:06:19 -04:00
bvandeusenandClaude Fable 5 1e7f66e72d feat(snippets): body provenance — the cache-with-provenance half of the pointer model (#2688)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Failing after 21s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 28s
Decision #2686: the recorded location is the source of truth for a
snippet's code; the stored body is a cache of it. data.provenance now
records what the cache is a cache OF — commit_sha + fetched_at — as a
carried JSONB field following the verification precedent, so no
migration is needed and absence keeps today's exact semantics.

The rules: provenance follows the code (fresh SHA restamps it, a code
edit without one drops it, a metadata edit carries it); writes ABOUT
the code carry it — record_verification rebuilds data from scratch and
would otherwise erase it silently; an ok verdict at a known commit
restamps it, since the checker just proved the cache matches the source
there. verify_snippet verdicts also record the commit they ran at,
making "the repo moved on since the check" computable once the forge
integration lands. create/update/verify MCP tools take commit_sha
(git rev-parse HEAD — free for any session with a checkout).

Unit tests pin the compose/carry logic; real-Postgres integration tests
run create→verify→update end-to-end (#2663: DB paths get no
mocked-only coverage).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 12:00:40 -04:00
bvandeusenandClaude Fable 5 8407368c0c fix(hooks): definition detector covers all code, not a language shortlist (#2682)
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 27s
ARM 1 extracted definitions with patterns for CSS/JS/TS/Python only —
the languages of the repo it was born in — so the local duplication
proof, and the #2664 record nudge gated on it, were structurally
unreachable in Go/Kotlin/Rust projects (Minstrel, FabledExchange):
precisely where recording was observed never to happen. One
modifier-strip plus a definition-keyword family (func/fun/fn/function/
def/sub, struct/trait/interface/enum/object/protocol/type, plus Go
method receivers) now covers them all; impl is excluded because several
impl blocks per type is normal Rust, and keyword-less declarations
(C/Java/Dart) are documented out of scope. Grep patterns mirror the
same forms so hits are definitions, never call sites. Parameterized
tests pin the coverage per language family. Plugin 0.1.30.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 11:33:24 -04:00
bvandeusenandClaude Fable 5 2d58e74ec7 feat(reuse): recording model becomes the pattern library — every shape at first build (#2687)
Decision #2686: snippets are the project's pattern library, not a dedup
net. The floor, the reusing-code skill, and the snippet tool docstrings
now state the proactive model — record every shape the first time it is
built, with no will-it-recur judgment, and start later instances from
the recorded shape; second-copy consolidation stays as the backstop.
The floor guard test pins all three elements (tool, first-build trigger,
backstop) so the model cannot silently regress to the reactive wording.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 11:33:24 -04:00
bvandeusenandClaude Fable 5 4107b17727 fix(telemetry): usage readout grouped by a rebuilt CASE — group by the label instead (#2663 root cause)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python tests (push) Successful in 1m0s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Build & push image (push) Successful in 39s
CI's new integration tests reproduced the outage on a clean database and
named the half: the writes land fine, and usage_for_notes fails on EVERY
call. The GROUP BY rebuilt the ambient case() expression, and asyncpg's
expanding IN-parameters give each instance its own bind names — so Postgres
sees a SELECT expression the GROUP BY doesn't cover and rejects the query
with a GroupingError, which the old code swallowed into zeros. One labelled
expression, grouped by its label. The deployed table has been accumulating
events all along; history appears as soon as this deploys.

Also: the two hook-execution tests now run with the real PATH and skip
when the hook's tools are absent (the restricted-PATH convention next door
is for silence contracts, where empty-for-the-wrong-reason still passes) —
and the unit lane installs jq so 'skip' never quietly becomes 'nowhere'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 21:49:48 -04:00
bvandeusenandClaude Fable 5 fb757fb4ba feat(reuse): recording gets a seam — the prior-art hook asks for create_snippet when duplication is proven and unrecorded (#2664)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 25s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Failing after 21s
CI & Build / Python tests (push) Failing after 31s
CI & Build / Build & push image (push) Skipped
Zero snippets were ever recorded outside sessions already thinking about
snippets: the read side had a real seam (the PreToolUse hook) and the record
side had a trailing clause of a floor bullet. The trigger moment — 'I just
wrote the second copy' — is mid-Write/Edit, so the nudge now rides the same
hook: when the local arm proves the definition exists elsewhere in the repo
AND Scribe returned no record of it, the context block asks for
create_snippet. Both gates or silence, so a brand-new helper and an
already-recorded one stay nudge-free.

Floor bullet promoted to name the trigger moments (extract, hoist, second
copy); guarded by test the same way the Systems reflex is. Plugin 0.1.29 so
the cache picks up the hook (#2209).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 21:45:32 -04:00
bvandeusenandClaude Fable 5 77acee9239 fix(telemetry): usage counters get canaries, task references, and the missing integration tests (#2663)
The deployed instance ran with every usage counter at zero while surfacing
demonstrably fired. Every unit test was green because every unit test mocked
either _schedule or the session — the two functions that touch the database
ran against real Postgres nowhere. Both telemetry writers also held no
reference to their fire-and-forget tasks (the loop keeps only weak ones), and
swallowed every failure into logger.debug, so a total outage was
indistinguishable from an unused corpus.

- note_usage + retrieval_telemetry keep strong task references until done
- failures log at WARNING; note_usage additionally drops one AppLog error row
  per process per site, so the admin UI shows the outage without host access
- integration tests cover _insert_events -> usage_for_notes and the full
  record_pulled chain on a running loop, splitting the write and read halves
  so a failure names its side

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 21:45:32 -04:00
bvandeusenandClaude Fable 5 5cb7cfe706 feat(processes): authoring contract — a process is a shape, never a script
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 26s
Operator's generalization of #2582: the benefit of nearly every stored
process is the accumulated SHAPE — steps, taxonomy, quality bar — and a
process must not force anything or overwrite the intent of the request
that invoked it. create_process now states the authoring side of the
composition contract: no embedded approach mandates, no pre-granted
approvals, clarify steps seed from the conversation instead of
re-asking it.

(All three stored processes were also audited: DRY Pass and Rulebook
Review were already propose-approve-apply with no forcing language;
both gained the seeded-clarify line — data changes, live already.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 09:40:23 -04:00
bvandeusenandClaude Fable 5 9c68faa0bd feat(processes): composition contract — the process is the skeleton, the conversation supplies the parameters
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 24s
Extends #2582's fix per the operator's direction: a stored Process must
incorporate the context it was triggered with, not displace it. No new
plumbing needed — the live context is already in the session; what was
missing is the stated contract. get_process now carries it: live
constraints/scope/focus fold into the procedure and override its
defaults where they disagree; a clarify step asks only what the
conversation has NOT already answered (confirm interpretations, don't
re-ask); stated concerns become lenses the procedure applies, not text
it discards. The sync-generated skill stubs state the short form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 08:39:02 -04:00
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
bvandeusenandClaude Fable 5 75bdb9c148 test(systems): fix two mock bugs in the seam tests — unset .id on the fake, shared to_dict dict leaking across creates
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 12s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 27s
Runs 3562/3565: _fake_system never sets .id so the dup-gate assertion
compared a MagicMock to 7; and a shared to_dict return_value dict let
the tagged create's mutation leak into the orphan create's response.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 15:55:56 -04:00
bvandeusenandClaude Fable 5 3455f9cb9a refactor(systems): one seam — the Systems question rides every read and write of a record
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 12s
CI & Build / Python tests (push) Failing after 29s
CI & Build / Build & push image (push) Skipped
Scribe issue #2570, from the operator's challenge: the invariant is
"always be asking whether what you're touching is a System's territory
and whether the work is filed there" — not a nudge in one corner. The
prior shape failed it twice: the hint fired only on creates (with a
special-cased zero-Systems branch), and get_task/get_note returned
records WITHOUT their Systems, so the read-side reflex had nothing to
fire on (same per-kind asymmetry as #2481).

- attach_systems(): single helper used by get/create/update for tasks,
  notes, and snippets, plus add_task_log. Tagged records always show
  `systems`; an untagged project record carries the `systems_hint`
  question instead. Neither field attaches empty (#2483). Hint is
  owner-only; everything fail-open (#2109).
- untagged_systems_hint unified to ONE question — the vocabulary
  listing varies, the question doesn't; the zero-Systems branch stops
  being special text.
- Docstrings state the uniform contract; floor prose now names the
  read-side reflex (systems visible -> list_system_records the pile).
- Plugin 0.1.27 -> 0.1.28.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 15:48:41 -04:00
bvandeusenandClaude Fable 5 56b1952de5 fix(systems): sweeps are the discovery moment — zero-Systems hint, create_system dedup gate, prose inverted
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 16s
CI & Build / Python tests (push) Failing after 29s
CI & Build / Build & push image (push) Skipped
First real-world test of the #2562 fixes (Scribe issue #2569): a Forge
session ran a whole-codebase audit and created zero Systems — endorsed
by the shipped guidance, whose "no particular area takes none" clause
read as an exemption for exactly the record type that enumerates the
subsystem vocabulary. And the systems_hint was silent for a zero-Systems
project, the one state nothing else nudges out of.

- systems_hint gains a zero-Systems branch: prompt the FIRST
  create_system instead of going quiet.
- create_system is duplicate-gated like the other creates (normalized
  name, archived included, fail-open) — liberal creation becomes safe by
  construction, so the guidance can stop preaching restraint.
- Prose inverted on every surface (hint text, create_system docstring,
  floor bullet, using-scribe step 7): audits/sweeps take several tags
  and mint the Systems they name; the gate is the guardrail against
  sprawl, not holding back; only a record genuinely about no particular
  area goes untagged.
- Plugin 0.1.26 -> 0.1.27.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 15:35:59 -04:00
bvandeusenandClaude Fable 5 3ff8803593 feat(instructions): fit the delivery fold — 2k server map, floor Systems reflex, write-time systems_hint
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 47s
CI & Build / Build & push image (push) Successful in 28s
Claude Code injects only the first ~2,048 chars of an MCP server's
instructions and silently cuts the rest mid-word (#2562, observed live):
_INSTRUCTIONS was 20,002 chars, so ~90% — including all Systems tagging
guidance — never reached any session. Rearchitect delivery around what
each surface actually delivers:

- _INSTRUCTIONS becomes a 1,997-char purpose-sorted map, with a header
  comment stating the budget and where detail belongs instead.
- Tool docstrings keep the per-tool HOW (audit: nearly all displaced
  topics were already duplicated there); backfill the four gaps —
  enter_project session scoping + project bootstrap, create_rule
  entity-vs-rule test, create_design_system not-a-rulebook,
  create_system two-records test.
- The plugin static context (the delivery floor) gains the
  tag-to-Systems reflex and a surfaces-layering statement; plugin
  0.1.25 -> 0.1.26 so the executing cache refreshes (#2209).
- create_task / create_note / create_snippet return a systems_hint when
  a record is created untagged in a project that has Systems — in-band
  at the exact write it applies to, fail-open like the dedup gate.
- Guards: _INSTRUCTIONS length budget, floor-states-the-reflex, and a
  displaced-topics sweep asserting every cut topic still lives on a
  delivered surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 12:53:35 -04:00
bvandeusenandClaude Fable 5 272b7dbddf feat(dedup): per-kind duplicate-report floors — notes/tasks default 0.93
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 42s
At chunk grain (#280) a note-pair's similarity is its closest chunk pair,
so the shared 0.82 floor saturated the note/task reports with related
families (38 note / 155 task groups against the 200-pair cap, measured
2026-08-09). Split kb_duplicate_threshold into per-kind settings keys
with per-kind defaults: snippet 0.82 (single-chunk, scale unchanged),
note/task 0.93 (points the report at genuinely-alike records). Settings
UI grows the two new knobs; report entrypoints inherit the change via
get_duplicate_threshold(user_id, kind).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-09 10:33:11 -04:00
bvandeusenandClaude Fable 5 23cb396e65 fix(recurrence): drop the now-unused child binding
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 27s
The spawn's explicit embed call went with #280 step 3; the create_note result
had no other reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-08 23:52:33 -04:00
bvandeusenandClaude Fable 5 041d8defbc feat(embeddings): best-chunk-per-note on every retrieval surface (#280 step 4)
CI & Build / Plugin hooks (push) Failing after 1s
CI & Build / Python lint (push) Failing after 3s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Skipped
A note's relevance is now its best chunk's similarity, everywhere:

- semantic_search_notes keeps the indexed raw-distance top-k and over-fetches
  chunk rows (x4, composing with the x3 supersession over-fetch), then
  collapses to first-appearance-per-note — rows arrive distance-ordered, so
  first is best. Every ranked consumer (MCP/REST search, Browse, auto-inject,
  write-path, gate) inherits through the one function.
- list_notes semantic q swaps its join for a correlated MIN-distance
  subquery — the join would have repeated a long note once per matching chunk
  and made total count chunks.
- the duplicate report groups its self-join by note pair on MIN(distance):
  pair similarity = closest chunk pair, and the < join now also drops
  cross-chunk self-pairs that would flag every long note against itself.
- the write gate queries once per chunk of the candidate (capped at 8), so a
  note duplicating an existing record in ONE SECTION is caught — the
  whole-document query diluted exactly the section that mattered.

Integration test now seeds a two-chunk note and pins the collapse against
real pgvector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-08 23:51:01 -04:00
bvandeusenandClaude Fable 5 0e70a3896b feat(embeddings): per-chunk rows — schema, write path, version-aware backfill (#280 steps 2+3)
note_embeddings becomes one row per chunk: PK (note_id, chunk_index), plus
chunk_text (what this vector actually encodes) and chunker_version. Migration
0077 clears the table — embeddings are derived (0067 precedent) and the old
whole-document rows are indistinguishable from single-chunk notes, so the
startup backfill regenerates the corpus at the new shape. The backfill is now
version-aware: a future shape change is a CHUNKER_VERSION bump that re-embeds
exactly the stale notes, not another wipe.

upsert_note_embedding takes (title, body) and chunks internally — one path
for the write path, the recurrence spawn and the backfill. The recurrence
spawn's own embed call is deleted outright: create_note already embeds via
embed_note (#2056), so the spawn was a second copy of the rule. An emptied
record now CLEARS its stale vectors instead of leaving them findable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-08 23:47:34 -04:00
bvandeusenandClaude Fable 5 6b5043a69c feat(embeddings): chunk_document — the chunked document shape (#280 step 1)
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Build & push image (push) Successful in 25s
bge-small reads 512 tokens and fastembed truncates silently, so a single
whole-document vector permanently lost everything past ~400 words. The new
shape: split at markdown headings (fence-aware), merge small sections, split
oversize ones at paragraph boundaries, title-anchor every chunk, repeat the
section heading on continuation pieces. A record that fits the window yields
exactly one chunk identical to the historical title\nbody shape, so the
corpus's sharpest records are byte-for-byte unaffected. CHUNKER_VERSION added
so later shape changes re-embed by version comparison instead of a table wipe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-08 23:43:51 -04:00
bvandeusen 4ba544e2af refactor(theme): retire the --color-* shim — the sweep it promised, run
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 37s
#2533. theme.css claimed "removing this block is a rename sweep across the
components, tracked separately" — written in 67a529a, never filed, which made
the comment itself an instance of the survey's presence-without-reference
pattern. This is that sweep.

73 alias declarations deleted; 69 files rewritten; every --color-*-style name
now references its --fs-* token directly. Mechanical by construction: the map
IS the alias block, applied longest-name-first with a boundary guard so
--color-text never matched inside --color-text-muted. Zero survivors outside
theme.css, verified by grep rather than assumed.

One deliberate survivor: --color-shadow stays DECLARED, because it was never
an alias — it is a literal value the design system has no token for. Marked
in place as a recorded gap: promote it to an --fs-* token when a second app
needs it, don't copy the line.

Nothing is lost mode-wise: the aliases' resolve-at-use-time trick (which
absorbed 48 dark-mode overrides) lives one layer down in the --fs-* tokens'
own derivations, which is why the sweep is a pure rename. Both CSS checkers
green.

Why now rather than never: check_snippets_against_design_system reports every
--color-* reference as "unknown — renders as NOTHING", and nine recipe
snippets recorded from components.css carried the deprecated names, making
them prior art pointing the wrong way. With the sweep in, the checker's
report over re-recorded snippets should be EMPTY — the acceptance test that
proves the checker was right all along (#2517's correction).

Refs #2533
2026-08-08 22:42:37 -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 9b3874b657 test: the auto-inject path now logs two retrievals, and that is the point
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 28s
45ba4aa made the reuse slot log its query (source: reuse_slot). The margin-gate
test pinned record_retrieval to exactly one call, which was asserting the very
asymmetry #2463 fixed — the displaced hit logged, the displacing query not.
Assert both sources in order instead.

Refs #2463
2026-08-08 22:32:58 -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 f11a547cd2 fix(lists): shared-project records appear, and a list's q means what search means
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 18s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 28s
#2462, both decided halves.

## The ACL defect

list_notes filtered on Note.user_id == user_id with no scope parameter at all
— never a per-call decision, the capability was absent. query_knowledge beside
it was deliberately browse-scoped with a comment saying why. So a task in a
shared project was invisible in list_tasks, enter_project's open-task list,
the SessionStart todo count and the web UI's task views, while the same
project's notes and snippets appeared.

Now the shared clause: notes_visibility_clause(user_id, "browse"). Browse and
not read, per decision #2094 — an ambient list must never surface a record
someone shared one-to-one; those stay search-only. This is the remaining half
of a fix made twice before (#2159 widened fetch, #2092 widened meaning), and
the guard below is what stops a fourth half appearing.

Guarded by source inspection in test_retrieval_scopes: every list-shaped
service references a shared visibility clause AND carries no bare
Note.user_id comparison that would quietly re-narrow it. An owner-only list
returns correct-looking rows and simply omits the shared ones — the shape no
behavioural test catches.

## q is semantic (operator: "make it match")

The UI's note list keyword-matched while the UI's Browse search
semantic-matched, over the same records, with nothing saying so. Now one
meaning: q joins the embedding index and orders by cosine distance at the
interactive floor, with every lifecycle filter still applied in the same
indexed query. Relevance ordering wins over `sort` when q is present — a query
is a relevance claim, and sorting its results by date would shuffle the
answer. ILIKE survives only as the embedder-down fallback: degraded, never
empty.

Stated position: superseded records are NOT demoted in list-q. The penalty
reorders a top-k; reordering a paginated, counted list would make page
boundaries lie. The search surfaces carry the demotion.

## The interactive floor becomes one constant

INTERACTIVE_SEARCH_THRESHOLD = 0.3 in embeddings.py, consumed by
routes/search.py (was a commented constant), knowledge.py (was a bare
literal), and the new list-q. This closes #2463's finding 3 early — the same
number lived in two files with the reasoning attached to only one.

## Deferred, deliberately

The return-shape unification (ORM objects vs dicts) stays undone. It is a
14-caller refactor whose motivation — callers being unable to swap paths —
shrinks now that both paths share the clause and the meaning of q. If swap
pressure recurs it deserves its own change, not a rider on an ACL fix.

Refs #2462, #2463
2026-08-08 19:58:01 -04:00
bvandeusen 52bf40de4f test: the empty duplicate report carries suggestion now — assert shape, not the exact dict
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 12s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 35s
The step-5 field addition (d7039dc) broke a test pinning the error-path
return literally. The field is deliberate: a failed scan must match a clean
scan in shape, or every consumer grows a second code path for the degraded
case. Assert the parts instead.

Refs #2547
2026-08-08 18:58:37 -04:00
bvandeusen d7039dc17c feat(dedup): the duplicate report reaches notes and tasks, with per-kind cures
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 17s
CI & Build / Python tests (push) Failing after 30s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Build & push image (push) Skipped
Step 5 of #278, folding in #2534. The operator's no-gate decision for the web
UI (#2482 — "an llm attached to this surface is the corrections system") has a
precondition nobody had built: the corrector has to be able to SEE what needs
correcting. find_duplicate_snippets had no equivalent for notes or tasks, so a
duplicate note was only ever noticed by accident.

find_duplicate_records(kind="snippet"|"note"|"task") — the same indexed
self-join, parameterised. Tasks are notes with a status, not a note_type, so
the kind split is a status predicate; mixing them would propose folding a
to-do into a write-up. find_duplicate_snippets stays as a wrapper because both
surfaces and SnippetListView consume it by name.

What differs by kind is the CURE, and the report says so in a `suggestion`
field rather than leaving the caller to guess:

  snippet  merge — lossless, the survivor keeps every call site
  note     NEVER merge. A correction pair → supersedes on the newer; state
           smeared across dated records → extract to the System's reference
           note; genuinely parallel → leave alone. Choosing needs the records
           READ, which is the agent's job — so non-snippet groups carry
           `members` with dates and any `existing_supersessions` already
           declared inside the group. A pair someone ruled on is not an open
           question.
  task     usually the same work opened twice — keep the one with the history,
           cancel the other with a pointer.

The snippet sibling filter stays snippet-only: it keys on symbol/code_sha,
which other kinds don't carry — and for them a look-alike is a finding.

Surfaces: MCP find_duplicate_records (classified into _READ_ONLY_TOOLS — the
completeness test would have caught the omission), REST /api/notes/duplicates,
and a KnowledgeView panel mirroring SnippetListView's — links only, no merge
button, because for notes the report proposes and the correction is a read-
and-decide act. The panel follows the type filter and clears when it changes,
so a note report can't linger under a task view.

Correcting the task's own premise: it claimed the snippet report had "no view
consuming it" — stale; SnippetListView has consumed it since it shipped. The
UI gap was only ever notes/tasks.

Answers the question carried from #2482: yes, the update routes on BOTH
surfaces can turn a record into a duplicate — the gate is create-time by
design. This report is the mechanism that catches it after the fact, which is
the model the operator chose.

Refs #278, #2547
2026-08-08 18:51:49 -04:00
bvandeusen 3f1523b19f feat(systems): read-side teeth — the vocabulary at session start, a search filter, and the state/chronicle instructions
CI & Build / Python tests (push) Successful in 46s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 18s
CI & Build / Build & push image (push) Successful in 25s
Step 4 of #278, product half. The audit that motivated it: one System in
project 2, thirty records tagged, nothing since July 28 — three days after the
feature landed. Not a discipline failure; retrieval was completely blind to
the association (zero references in embeddings, knowledge, search, auto-inject,
or enter_project), so tagging was a write-side label with no read-side payoff,
and labels nobody reads don't get maintained.

Three changes, ordered by what makes the others workable:

1. enter_project returns the project's Systems (id, name, first line of the
   charter). Load-bearing for the tagging instruction: you cannot ask an agent
   to check a record against a vocabulary it never sees. Trimmed because it
   rides on every session start; the full charter stays get_system's job.
   Present-and-empty rather than absent when a project has none — "no named
   areas yet" is information the create-the-System instruction acts on.

2. search accepts system_id, MCP and REST (#33). Implemented once in
   semantic_search_notes as an EXISTS against record_systems — an association
   filter deciding candidate-set membership before scoring, like project_id,
   not a ranking signal. The REST route's missing project filter stays #2463's:
   it carries a default-scope UI decision this change must not preempt.

3. The instructions (#119, _INSTRUCTIONS + using-scribe skill; plugin 0.1.25
   for the cache):
   - Tag as you write, with an executable test — "would someone investigating
     that subsystem want this in the pile list_system_records returns?" —
     rather than "tag appropriately", which is what died.
   - Create the System when the area has no record: the two-or-more test
     snippets use, plus "don't wait to be asked to name an area that plainly
     exists", because the agent's default was leaving un-modelled areas
     un-modelled forever.
   - State vs chronicle: dev-logs are written once and never rewritten; durable
     findings live in the System's reference note, updated in place — safe
     because note versions are the changelog, which has existed since the
     feature shipped and was never named as one.

list_system_records' docstring now sells it as the way to READ a subsystem,
reference note first. No auto-inject boost by System — vocabulary and filter
first, measure before adding ranking behaviour (the #2486 lesson).

Refs #278, #2546
2026-08-08 18:19:58 -04:00
bvandeusen 6c4c1bccfc test: stub the auto-inject supersession lookup where there is no database
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 27s
CI on f20c019: nine failures across three files, all on the auto-inject path.
Step 3's own tests passed; these are the same shape as the step 2 breakage —
a real database call added to a path whose unit tests run without one.

Stubbed per file, each saying why, rather than once in conftest. A global stub
would hide the dependency from every future test on these paths too, which is
the same "make the code lie" trade refused in 984407f, one level up.

The auto-inject query is kept separate from the ranker's rather than threaded
through, and that is deliberate: `_reserve_slot_for_reuse` runs a SECOND search
and can add a hit to the menu, so the final `kept` set is not a subset of what
the ranker scored. Labelling whatever actually reached the menu needs its own
lookup over that final set — one indexed query on a handful of ids.

Refs #278
2026-08-08 02:07:14 -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
bvandeusen 984407f931 fix(supersession): one query for both directions, not two per note read
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 25s
CI failed on 8d9e96c — eight tests in test_mcp_tool_notes.py, all
"Connect call failed (127.0.0.1, 5432)".

The proximate cause is that `_attach_supersession` runs on every note
read/write and those are unit tests of the tool layer with no database. But the
test failure exposed a worse decision underneath it.

I had written the two directions as two service calls, so every `get_note`
made TWO extra round trips plus TWO ACL checks — on the hottest path in the
product — to save a two-line partition in Python. That is the wrong trade
whether or not a test noticed.

`get_relations` replaces both: one OR query, one ACL check, partitioned by
which column holds the note's id. Its test asserts `execute.await_count == 1`,
so the collapse can't quietly come apart later.

The tests then get an autouse stub rather than the code getting a swallow. The
tool genuinely has a new dependency; hiding that behind a try/except to keep
unit tests green would be arranging for the code to lie about what it does.
This file already records the same hazard for note 2109, so the stub sits next
to that precedent.

Added the test that matters, which the first pass missed: a superseded record
still surfaces, so an agent WILL read stale material — and it must arrive with
a plain-language warning, not just a numeric field to notice. Also pinned that
both keys are ABSENT rather than present-and-empty when there are no relations.

Refs #278
2026-08-07 22:45:56 -04:00
bvandeusen 8d9e96cc6d feat(supersession): declare it — supersedes on both write paths
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 16s
CI & Build / Python tests (push) Failing after 31s
CI & Build / Build & push image (push) Skipped
Step 2 of #278. Records and reads the claim; the demotion that makes it matter
is step 3.

`services/supersession.py` with set/get on both directions, following the
set_record_systems shape since this is the same kind of mutable M2M at the
tool/route layer rather than inside notes_svc.

## Both directions are exposed, and only one is obvious

`supersedes` is what the author claimed. `superseded_by` is what a READER needs
and what the note itself cannot know — a stale record handed over with no
marker gets acted on confidently, which is worse than never surfacing it. So
get_note carries it, says so in its docstring, and adds a plain-language line
telling the reader to open the newer note first.

Both are OMITTED when empty rather than serialised as empty lists. A field that
always says nothing trains readers to skip fields — the lesson consolidated_at
cost, removed in the previous commit.

## Refuse vs drop, which is the one real judgement here

Dropped silently: a target that doesn't exist, is trashed, is the note itself,
or would close a cycle. Each is a claim with no subject or no meaning; none is
something the caller can act on.

REFUSED with PermissionError: a target the caller can read but not write.
That is the single case where the caller could believe they succeeded and be
wrong in a way that matters — demoting someone else's record out of their
retrieval is damage invisible from the outside, with no symptom for the owner
to trace. Rule #47, and PermissionError because services/snippets.py already
uses it for read-but-not-write with both surfaces catching it.

The PATCH/PUT routes scope by the CALLER, not owner_uid: an editor-share holder
may edit the note and must not thereby inherit the owner's write access to
whatever they name as superseded.

## Cycles

A ring claims every member is obsolete. Under flat demotion that demotes them
all equally, so the set drops out of ranked retrieval together with nothing in
the data saying why. Refused by walking the existing graph from the proposed
target — iteratively with a visited set, because the graph is user-supplied and
a deep chain must not become a stack overflow on a write path. The visited set
also makes the walk terminate on a ring that already exists, which is pinned by
its own test rather than trusted.

Both surfaces (#33), the instruction surface per #119 — framed as the third
answer beside update-instead and force=true: not everything resembling an
existing record should be folded into it, and not everything distinct should
compete with it forever.

Refs #278
2026-08-07 22:38:09 -04:00
bvandeusen 5dcb738ce8 fix(backup): carry note_supersessions — the coverage guard caught the omission
CI & Build / integration (push) Successful in 13s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Build & push image (push) Successful in 36s
CI failed on 45c6b1c. `tests/test_services_backup.py` asserts every table is
either in `_BACKED_UP` or explicitly in `_NOT_INCLUDED`, and the new table was
in neither.

That guard exists for exactly this: a new table gets a model and a migration —
both of which fail loudly if wrong — and then silently never gets a backup
section. No error, no warning, and a restore that reports success. Its comment
says it was written to "stop the seventh" table slipping through. This was the
seventh.

Supersession claims are backed up rather than excluded because they are a
JUDGEMENT. Someone decided this note replaced that one, and nothing in either
note's text records the decision. Lose them and a restored corpus silently
returns to ranking stale material alongside current material — with no symptom
that says why.

Scoping differs by backup kind, deliberately. A whole-instance export takes
every row. A single-user export takes only claims where BOTH ends are that
user's notes: a claim spanning out to someone else's record cannot be restored
into a single-user import, since the far id is not in the map, so exporting it
would write a row that silently vanishes on the way back in.

Restore maps both ids and skips the row unless both resolve. A claim is about a
PAIR — half of one is not a weaker claim, it is a dangling row pointing at
whatever note holds that id next. Guarded by `data.get` like every post-v2
section, so v5 and older payloads restore cleanly without it.

BACKUP_VERSION 5 -> 6, and the version test moved with it. A payload section
added without moving the version produces backups that are structurally
different and indistinguishable by inspection.

Refs #278
2026-08-07 21:49:08 -04:00
bvandeusen 45c6b1c88a feat(supersession): the relation, and the dead column that stood where it should
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Python tests (push) Failing after 32s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 25s
Step 1 of #278. Structure only — nothing reads or writes the new table yet.

Old records outrank newer ones on the same subject because a similarity score
cannot tell time. A note that accurately described how something worked in June
is still accurate ABOUT June; it is just no longer the answer. Nothing recorded
that, so nothing could act on it.

`note_supersessions(superseder_id, superseded_id)`. The claim points FORWARD —
the newer record names what it overtakes — because the older one cannot know it
has been overtaken; asking it to record its own obsolescence is asking it to
predict the future.

A table rather than a column because the relation is genuinely many-to-many and
partial, and both directions are hot: superseded_id answers "has this been
overtaken?" at ranking time, superseder_id answers "what does this replace?" in
a record view. An array column serves one and not the other.

CASCADE is safe because trashing is not a delete — trash_svc stamps deleted_at,
so a trashed note keeps its claims and restore brings them back. It fires only
on purge_trash, where a claim about the row would be unactionable anyway. A
CHECK rejects self-supersession, which under flat demotion would let a record
demote itself.

## consolidated_at, and what it actually was

Dropped. Written by nothing while serialised into every note and task payload
as null — and worse, it implied a capability.

The survey (#2483) read it as note consolidation modelled and abandoned. That
was wrong, and the frontend is what says so: `TaskViewerView` rendered
"✦ Auto-summarized from work logs" gated on this column. It is a survivor of
the pre-pivot auto-summary subsystem (migration 0030), whose own column #599
removed. Not an unbuilt feature — an outlived one.

So four more remnants went with it: the banner, its CSS, a `consolidatedAt` ref
in TaskEditorView assigned and never read, and `.auto-summary-banner-editor`
styling with zero template usage. That last one is presence-without-reference
in the same family as the column itself.

Dropped rather than repurposed for supersession, and the distinction is the
point: consolidation folds records into one survivor and destroys the
originals. Supersession is the opposite — both survive, the older ranks behind.
Smuggling one in under a column named for the other would bury that in schema.

## The hard delete_note

Removed, with a comment where it stood. Zero callers, and the danger was never
that it ran — it is that it was findable by name. Someone wanting to delete a
note greps `delete_note`, finds a function in the notes service with exactly
the right signature, and permanently destroys a record every path downstream
expects to be recoverable. The MCP tool of the same name already went through
trash_svc; only the service function was the trap.

Refs #278, #2483
2026-08-07 21:40:51 -04:00
bvandeusen bbba0b3ae3 refactor(embeddings): one definition of the document a record is embedded as
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 26s
`f"{title}\n{body}"` was written out four times. #2486 found three — the write
path, the recurring-task spawn, the startup backfill. The guard added here
found the fourth immediately, and it was the one that mattered most.

`dedup.find_duplicate_note` built the same string as a QUERY, compared against
embedded documents. Shaped differently from the corpus it searches, the gate
degrades silently: it still returns neighbours, just less apt ones, and nothing
says the query and the index stopped agreeing. The spawn path has the same
shape of risk — a recurring task embedded differently from everything else is
ranked against documents it doesn't match.

None of the four had diverged. That is what makes this worth doing now rather
than after: they are identical today, so collapsing them is a no-op, and the
whole point is that the next change to the shape can't hit three of four.

Which is imminent. #2486 measured a dev-log separating from five unrelated
dev-logs by 0.023 where a snippet separates by 0.153 — the difference being
that a snippet states its purpose twice in a short document. Whether that shape
is right is the open question; testing an alternative against four copies would
mean testing a shape that isn't the one in production. This is the precondition
the issue named.

The guard is source inspection, matching the f-string pattern rather than a
variable name, so a copy that renames its locals is still caught. A behavioural
test cannot see this: an inlined copy produces the same string today and
diverges the day the shape changes.

Refs #2486
2026-08-07 13:01:45 -04:00
bvandeusen 3f26aa9485 fix(project): the board showed 100 of 166 tasks and said nothing
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 42s
The milestone progress bars and the cards beneath them came from different
places. The bar is counted SERVER-SIDE over every task; the kanban rendered
whatever a single `limit=100` returned. Project 2 has 166 tasks, so 66 never
arrived — and because the route sorts `updated_at desc`, the ones dropped were
the least recently touched, which is mostly done tasks in completed milestones.

So "v1.0 — 12/12" expanded to two cards, and the auto-collapse rule (100% done
starts collapsed) read as arbitrary because the number driving it disagreed
with what you saw when you opened it.

No benefit was being chased. The limit shipped the day the view was written
(012eb1d, March 2), when the project had a couple of dozen tasks. It became
wrong as the corpus grew, and nothing was watching: the route returns `total`
and the view discarded it. Correct when written, wrong later, silent in between
— the same shape as half the coherence survey.

Four changes:

- **Page until complete.** The board groups by milestone and shows per-milestone
  progress, so it cannot be right on a partial set. Guards against a page that
  returns nothing while `total` still claims more, rather than looping forever.

- **Stop swallowing the error.** `catch {}` left an empty board, which is
  indistinguishable from a project with no tasks — the same hidden-with-no-
  indicator failure one layer up. Styled apart from the empty state deliberately;
  "no tasks" and "the tasks did not load" must not look alike.

- **Clamp long plan bodies** to ~6.5rem with a Show more. A milestone IS the
  plan, so its body carries the whole design — several hundred words now — and
  rendered in full one plan pushes every other milestone off screen. max-height
  rather than line-clamp: the content is rendered markdown with block children,
  which line-clamp handles unpredictably. Length judged on the source string; a
  per-milestone scrollHeight measurement is a lot of machinery to decide whether
  to show one button, and the proxy is only wrong near the threshold.

- **Auto-collapse decides ONCE per milestone.** It re-ran on every reload, and
  `loadMilestones` runs after a task's status changes — so expanding a finished
  milestone and ticking anything snapped it shut again with no visible cause.
  That is the other half of why the collapse state looked mixed: it wasn't only
  deciding at start, it was overriding the reader continuously.

Reported by the operator after the fd7097c deploy. Not caused by it — but
restoring `.milestone-header` in #2444 is what made the progress track render
again, so the mismatch had been invisible rather than absent.
2026-08-07 08:20:49 -04:00
bvandeusen 24d071619b fix(dedup): compare the artefact, not the prose describing it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 25s
The snippet gate was reading the wrong field, and #2464's UI recipes made it
measurable in both directions at once:

  .btn-danger vs .btn-danger-outline   0.92   siblings, BLOCKED
  .btn-primary re-recorded verbatim
  under a different name              <0.90   a literal copy, ALLOWED

The second is what settles it. Identical code at an identical repo·path·symbol
sailed through because the description differed, while two deliberately
parallel variants were refused because theirs did not. A snippet's embedded
document is mostly prose ABOUT the code, so no threshold fixes this: lowering
it blocks more siblings, raising it admits more copies.

So structure decides. Two exact signals, both index-served off the notes.data
mirror that already exists, no migration and no backfill:

  location  the same named thing in the same file. Requires BOTH path and
            symbol — a path alone is a directory of artefacts, and matching on
            it would refuse every second recipe from one stylesheet.
  code      byte-identical code anywhere, via the same fingerprint the drift
            check uses.

The semantic arm survives as a backstop for a genuine reword that shares
neither, raised to 0.96 so it sits above the 0.92 band where real variants
live. Structural hits say what they matched instead of hedging with "similar",
and point at merge rather than update — two records of one artefact is what
merge exists to fold back together.

find_duplicate_snippets gets the same correction: pairs where both snippets
name a symbol, name DIFFERENT symbols, and hold different code are variants,
not copies. Without it a design system's button family reports as one merge
set — eight recipes, every direct pair over the floor, top score 0.92, one
click from collapsing a component family. The cost is real and stated in the
code: a helper recorded twice under two names no longer reports. That trade
favours the report being usable, and same-symbol and unnamed duplicates — how
re-recording usually looks — still surface. The filter fails open, so a lookup
failure degrades to the old unfiltered report rather than to a reassuring
empty one.

resolve_locations extracted: compose_body, create_snippet and now the gate each
had their own copy of the repo/path/symbol shorthand fallback, and the gate is
the one where a disagreement would mean matching a location the record won't be
stored with. Applied to both create surfaces (#33) — the web UI must not be the
way to record what the agent was stopped from writing.

Refs #2518, #2464
2026-08-06 11:31:02 -04:00
bvandeusen c18139622c fix(telemetry): the human half of the pull ledger recorded one kind in three
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 20s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 27s
Opening a snippet in the UI recorded rest_snippet. Opening a note or a task
recorded nothing — so the most direct evidence the product has that anyone
cares about a record existed for one kind out of three, and the other two sat
at zero pulls looking like dead weight beside a kind that merely had a counter.

Not an open question about intent: models/note_usage.py already documented
'rest_note' as a source value. Nothing wrote it. The design named it and the
implementation stopped at snippets.

Adds rest_note and rest_task. Tagged by SURFACE rather than by the record's
kind, matching rest_snippet — the kind is a join away, but which surface asked
is not recoverable after the fact. The mcp_/rest_ split stays load-bearing:
"is this dead weight" is served by any pull, "was that injected line useful" by
agent pulls alone, and a human clicking a link would inflate exactly the number
#1038 and #2085 gate on.

The vocabulary comment in the model was itself the stale-enumeration shape this
survey keeps finding — it named a source nothing wrote while omitting sources
that existed. Replaced with the naming CONVENTION plus a pointer to grep, which
cannot drift, rather than a longer list that would go stale the same way.

Guard extended to the REST surface, same derivation as the MCP half: a route
registered at exactly /<int:x> for GET is a detail view, and one reaching a
note-backed loader must record. Handler source is expanded one level through
module-private helpers, without which get_snippet_route — the route that
already got this right — would drop out of the check by loading via
_load_snippet. Verified the guard fires when a call is removed.

Renamed test_mcp_pull_telemetry.py -> test_pull_telemetry.py; it is no longer
only about MCP.

Closes #2476
2026-08-06 08:51:21 -04:00
bvandeusen ac1ce0a7f0 fix(mcp): a read key could read notes but not snippets or design systems
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 28s
_READ_ONLY_TOOLS fails closed, which is the right design — but the list had
gone stale, so a read-only key could get_note and not get_snippet, both pure
reads of the same table, and could not read a design system at all. That
inverts the sensitivity ordering: the free-text records were reachable and the
structured, low-sensitivity ones were not. `find_duplicate_snippets` sitting in
the list was the tell — someone classified the report and missed the getters
beside it.

Adds the twelve reads that were missing: snippets, processes, the six design
system tools, and list_repo_bindings. Each verified to mutate nothing rather
than assumed — this is a security boundary, and a wrong entry does not cost
what a missing one costs. record_pulled on four getters is telemetry about the
read, not a change to what was read, and get_note already carried it inside the
boundary.

The list stays explicit. Deriving it from the name would be worse than
staleness: it makes the boundary follow a naming convention, so any future
get_* grants itself access. list_starter_role_groups is the live illustration —
it reads a constant, but names create_design_system in its docstring, so a
pattern-matcher flags it.

So derive the CANDIDATES and keep the DECISION explicit: a new test asserts
every read-shaped tool appears in _READ_ONLY_TOOLS or in a declared
_DELIBERATELY_WRITE_SCOPED, and that neither set names a tool that no longer
exists. Adding a getter now forces a classification at review time instead of
denying it silently. The second set is empty and stays declared — otherwise a
future get_or_create_* would be pushed into the allow-list to make the test
pass, which is the wrong way to satisfy it.

Third instance of the same shape, after #2476 and #2444: a hand-written
enumeration that missed the members added after it was written.

Refs #2496
2026-08-06 08:43:10 -04:00
bvandeusen ffd08507f1 fix(instructions): the push is an optimisation, not the bridge
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 29s
_INSTRUCTIONS told an agent the SessionStart hook was how rules reach a
session, and used that as the argument against a host-memory pointer. The
using-scribe skill said the opposite — pull them yourself, treat any push as a
bonus. Nothing said which wins, and #119 makes these surfaces the
specification, so this was the product behaving two ways.

#2198 is the case that settles it: every plugin hook was silently inert for an
extended period. An agent trusting the push would have run with no binding
rules and no signal, while those rules govern branch, commit and push.

So: _INSTRUCTIONS now leads with the explicit pull and names the hook as a
delivery optimisation. The argument against a host-memory pointer survives —
it never needed the hook to be reliable, because the pull IS the bridge and it
is written into every surface a session already loads.

The static context gains the tiebreaker for the next disagreement: follow the
surface that assumes least about its own delivery. "Most detailed wins" is
wrong precisely because the most detailed surface is the one with a delivery
precondition. It goes there by its own logic — a tiebreaker arriving over MCP
cannot arbitrate what to do when MCP is absent.

Guarded by tests/test_instruction_surfaces_agree.py: every session-start
surface states the pull, and no surface names the push without it. Plugin
version bumped so the cache that executes actually picks the file up (#2209).

Refs #2497
2026-08-06 08:23:36 -04:00
bvandeusenandClaude Opus 5 63c213b617 fix(processes): the least-equipped kind is the one that gets followed
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 18s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 41s
Survey pass 3 (#2250) tabulated capabilities per record kind. Processes came
out lowest on every column, and they are the kind with the most authority:
build_process_manifest turns each one into a skill file on the operator's
machine that auto-surfaces and is followed as written — its own docstring calls
it "the most consequential passive surface Scribe has."

Three gaps closed.

NO PULL TELEMETRY (#2476). get_process recorded nothing, while the auto-inject
menu header names get_process as the way to open that kind. Every note is
embedded regardless of note_type, so a Process is surfaceable — and the getter
the product points at was the one getter that recorded nothing, leaving every
Process permanently at zero pulls and looking like dead weight beside kinds
that merely had a counter.

get_note's own comment already listed processes as a reason to record pulls.
The fix for #2245 covered notes, tasks and snippets: it enumerated the kinds
someone thought of rather than the kinds that exist.

NO DEDUP GATE. create_process had no near-duplicate check and no force flag,
while notes, tasks, snippets and rules all have both. It matters more here than
elsewhere: two near-identical procedures don't just bloat the corpus, they
compete to be followed, and which one wins is decided by a slug collision.

NO DELETE. list/create/get/update, no delete — a kind that reads as one you
cannot retire. Deletion was always possible via delete_note, since a Process is
a note and the trash is kind-agnostic, so this was discoverability rather than
capability. delete_process checks note_type before trashing: the tool is
reached for by name, and letting it destroy an ordinary note whose id happened
to resolve would be a destructive action taken on a mistyped argument.

THE GUARD, which is the part that stops a fourth repeat.

tests/test_mcp_pull_telemetry.py discovers every get_* MCP tool by AST and
requires a record_pulled from any that loads a single note. Not a list of
getters — a get_<newkind> added tomorrow is covered the moment it loads a note
the way the others do. get_milestone is correctly excluded: it calls list_notes
for a milestone's steps, which is a surfacing, not an opening.

The loader NAMES are a list, and that residual weakness is pinned against a
rename rather than papered over. An earlier draft tried to discover new loaders
by return annotation and would have failed on create_note — which also returns
a Note. Readers and writers aren't distinguishable by type, so the honest
version is a pinned list, a non-empty assertion, and a docstring saying which
hole remains.

test_register_attaches_four_tools became a derived check of the module's public
coroutines, so the next tool added can't be left unregistered.

MCP _INSTRUCTIONS updated: product behaviour belongs in the instruction
surfaces, not in a rule (rule #119).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 16:32:05 -04:00
bvandeusenandClaude Opus 5 07bf58de46 fix(project): grid tracks that cannot shrink pushed the milestone rows off-page
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 43s
Reported after deploy: the milestone rows and the kanban's Done column run past
the right edge and get cut.

Both grids here use a bare `1fr`, and a `1fr` track carries an AUTO minimum —
it cannot size below its content. So one wide descendant anywhere in the
content column widens the column past the grid, everything inside inherits that
width, and `.project-view`'s `overflow-x: clip` cuts it at the page edge. The
milestone header only made it visible: it is a flex row now, so its tail
(progress track, percent, actions) sits at the right edge where the clipping
happens, where before those children stacked at the left and never reached it.

`minmax(0, 1fr)` on both, plus `min-width: 0` on the content area — a grid
item's default `min-width: auto` refuses to shrink even when its track will,
so the two halves are needed together.

Worth naming, because it is the same property twice with opposite intent: the
header nav was fixed two commits ago by RELYING on the auto minimum, so neither
side could be squeezed under its content and the pill bar stays centred. Here
that same behaviour is the defect. `1fr` is not a neutral default — it is a
statement that the track may not shrink.

I could not isolate which descendant was the wide one by reading, and said so
rather than guessing at it; this is the structural fix, which holds whichever
of the candidates it was.

Not changed: RulesView's `280px 300px 1fr` is the same shape and a plausible
latent instance, but nothing has reported it and I have not seen that surface
misbehave. Guessing at unreported layouts is how eleven fixes become eleven
regressions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 10:08:48 -04:00
bvandeusenandClaude Opus 5 a6d6550483 fix(ui): walk the eleven dangling-style reports — two were real
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 45s
#2444. Each needed reading rather than a batch fix, and the split was 2 real
losses, 4 false reports, 5 wrappers that are bare on purpose.

REAL:

  .system-card   was a flex row, and every child still says so —
                 .system-swatch and .system-actions are flex-shrink: 0,
                 .system-body and .system-form--inline are flex: 1.
                 align-items: flex-start is why the swatch carries
                 margin-top: 0.3rem: nudged onto the first line of text.
  .systems-list  no rule AT ALL, so the systems list rendered with browser
                 bullets and indent. Invisible to the check — see below.
  .graph-embed   the panel is a flex column whose header is flex-shrink: 0,
                 so this is the item that takes the remaining height. Without
                 it the `height: 100%` on the line below resolves against auto
                 and does nothing, which left the comment above it specifying
                 a rule that could not work.

FALSE REPORTS, and the checker was wrong rather than the code:

`.pane.empty` and `td.num` are base rules for the element that carries those
classes — the check read any compound with more than a lone class as a
modifier. It now records a compound's whole class SET and clears an element
carrying all of them, which is exact: recording the classes individually would
have cleared `.pane` everywhere on the strength of a rule that only applies
alongside `.empty`. Four reports gone, and a check with false reports is one
that gets skimmed.

BARE ON PURPOSE — .rb, .topic-group, .new-topic, .sub-list, .dash-head, and
both .detail-row rows. Each namespaces descendant rules and assumes nothing
about layout, which is the tell that separates them from a deleted base. All
seven now carry a comment saying so, so the next reader doesn't re-litigate
them and a NEW entry in the report means something actually changed.

Also recorded in the script: it cannot see a class with no rule anywhere, since
that is indistinguishable from a semantic-only hook. `.systems-list` was found
by reading the file beside a class that WAS half-styled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 09:26:35 -04:00
bvandeusenandClaude Opus 5 46271ccaa7 fix(project): the goal field is a textarea, not a one-line input
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Build & push image (push) Successful in 39s
A project goal is a paragraph in practice. This one rendered as "Maintain
Scribe as the reliabl" with no way to read the rest but arrowing through it,
in a sidebar with room to spare. Description gets two more rows for the same
reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 08:42:58 -04:00
bvandeusenandClaude Opus 5 6ac821178f fix(design): declare --tp-fill so the token check can see it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Canceled after 32s
The swatch set it inline only, and a custom property that exists nowhere in a
stylesheet is exactly what check_design_tokens reports as unresolvable — it was
right, and it caught this on the commit that introduced it.

Declaring it on .tp-swatch is the real fix rather than a silencer: a token that
resolves to nothing now renders as bare checks instead of an invalid gradient,
which is what the inline value would produce when empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 08:41:24 -04:00
bvandeusenandClaude Opus 5 4a9744172f fix(ui): restore four base rules a CSS sweep deleted, and check for the rest
CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / integration (push) Successful in 27s
CI & Build / Python tests (push) Canceled after 38s
CI & Build / Build & push image (push) Canceled after 0s
Operator reported four things looking wrong. Two were the same bug, and it is
not a design drift — it is deleted CSS.

Removing a rule from a scoped stylesheet leaves its modifiers behind. The
selector still exists, so nothing reads as unused, and the element renders with
no base styling at all:

  .btn-workspace      base gone, :hover survived — the Workspace link rendered
                      as raw browser blue, underlined
  .milestone-header   base gone, .clickable and :hover survived. Every child is
                      written for a flex ROW (.ms-name { flex: 1 }, the progress
                      track, .ms-pct), so without the parent they stacked and a
                      one-line milestone became five. That is the "projects
                      section uses space poorly" — a deletion, not a redesign.
  .milestone-group    no rule at all; the card around each milestone
  .ds-header          only its h1 descendant survived

vue-tsc cannot see any of it. A dead style typechecks perfectly.

scripts/check_dangling_styles.py finds the shape: an element whose every static
class has no base rule anywhere, while at least one carries modifier rules. It
reports 11 more. Reported and not gated, because a genuinely bare wrapper is
legitimate — the signal is the count growing. Runs in the lint lane, stdlib
only, and knows no class name or convention (rule #115).

Also from the same report:

- The header pill bar was `position: absolute; left: 50%`, so it did not
  participate in layout: out of room, it OVERLAPPED the brand and the utility
  cluster instead of pushing them. A sixth link reached that at ~1270px, an
  ordinary window. Now `1fr auto 1fr` — a 1fr track has an auto minimum, so
  neither side can be squeezed under its content and the two stay equal, which
  is what keeps the bar centred in the viewport rather than in the leftover
  space. Overflow becomes the header growing, not two things sharing pixels.

- The token preview put its checkerboard on the whole specimen stage, so every
  swatch sat in a frame of checks and the pattern read as the loudest thing on
  the page. The checks now sit UNDER the colour as a second background layer:
  an opaque value hides them, a 15% tint shows exactly as much as it should.
  Text-bearing specimens lose the box entirely, and name/value/purpose are one
  line each with the full text on hover — they wrapped freely before, so a card
  was two lines tall or five depending on how long its color-mix() happened to
  be, and the grid had no rhythm.

- .btn-cta joins the shared button family: the gradient-and-glow brand moment
  the system carries tokens for, which had been living in one view's scoped
  block. That is what made it deletable. The header actions are now one size
  and one family instead of four sizes and two.

- The shared button shape gained inline-flex + gap, so a button carrying an
  icon centres it without each caller rebuilding the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-05 08:40:41 -04:00
bvandeusenandClaude Opus 5 8087ba4db0 feat(design): a project reports drift in its own recorded components
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 43s
The check has taken a project id since it was written — check_snippets_against_
system(user_id, design_system_id, project_id=0), and the route has always read
?project_id=. Nothing on the frontend ever passed one and no project-side
surface existed, so the capability shipped and stayed unreachable.

A Design tab on the project, beside Systems and Rules, reporting three things
per snippet:

  no such token     var(--x) the system doesn't declare. Renders as NOTHING —
                    no error, no failing test, just an element quietly unstyled.
                    Leads for that reason.
  defines its own   a component minting a custom property instead of reaching
                    for the shared one. This is the DRY finding and the reason
                    the surface exists: the codebase re-solving a solved
                    problem, one component at a time, visible only when someone
                    changes the shared value and half the components don't move.
  write the token   a literal the sheet says to stop writing, paired with what
                    to write instead.

Three empty states, kept distinct, because collapsing them is how a check comes
to sit dead: no design system bound, no snippets recorded (nothing was
checked), and checked-and-clean. The last one says how many were checked.

Bound to the SAVED pointer rather than the sidebar picker's draft, so an
unsaved change can't make the tab report against a system the project isn't
using.

Scope is recorded code, per the operator: snippets are what Scribe holds, and a
repository's own sources are checked where they live, by that project's CI.

Step 3 of milestone #274.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-04 10:41:49 -04:00
bvandeusenandClaude Opus 5 7b0984579d feat(design): preview any design system, resolved from the record
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 44s
The record view listed values as text and drew a swatch only where the value
looked like a colour. Two problems, one cause: a derived value such as
color-mix(in srgb, var(--accent) 15%, transparent) was drawn by resolving
--accent against THIS app, so previewing another project's system showed
Scribe's palette. It looked right, which is why nobody noticed.

TokenPreview draws the system from its own record. Every value is resolved on
an offscreen probe carrying only that system's declarations, so a system whose
app this browser has never loaded renders in its own colours — which is the
difference between a tool and a mirror.

Specimens are chosen by value SHAPE, never by name: colours become swatches,
lengths become rules drawn to scale, gradients and shadows get a surface, font
stacks are set in themselves. Nothing matches --fs-space-* or any other
convention, because the convention belongs to the install (rule #115) — a
system that calls its spacing --gap-N gets the same treatment. Translucent
values sit on a checkerboard, or a 15% tint over a solid card reads as opaque
and shows the wrong colour.

Modes come from the system, not from the app: a system declaring base and
light offers both, independent of the theme this page is in.

The provenance list keeps its swatches only for self-contained colours — the
ones needing no resolution, which it can therefore draw honestly. Everything
with a var() inside is left to the preview built for it.

Step 2 of milestone #274.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-04 10:39:45 -04:00
bvandeusenandClaude Opus 5 dcd4efcea0 refactor(design): retire /design — a surface that could only inspect itself
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 40s
The design surface is for the projects an install tracks. /design read the
running app's own stylesheet — names out of a bundled theme.css, values out of
getComputedStyle(document.documentElement) — so it could only ever describe the
instance serving the page. Scribe is one project among the projects Scribe
tracks; it gets no view hardcoded into every install.

The mechanism that makes this a tool rather than a mirror already existed and
already covers Scribe: scripts/check_design_tokens.py runs in CI against a
sheet path it knows nothing about, using check_code_against_tokens — the same
engine behind check_snippets_against_system. /design was redundant even here.

Removed: DesignView, DesignTabs (nothing left to tab between), api/design.ts,
routes/design.py and its blueprint, the /design route, ui_design_system() and
its setting, and the Settings picker that designated "this app's UI".

utils/designTokens.ts and utils/designDrift.ts go with it — between them they
were the browser-reading half. What survives is utils/designValues.ts, which
works on a record rather than a document: valueForMode, modesPresent, and
resolveDeclared.

resolveDeclared gained real isolation in the move. Custom properties inherit
and `all: initial` does not reset them, so a probe sitting in this page would
resolve any reference a record leaves undeclared against the SURROUNDING app's
tokens — previewing another project's system would quietly borrow this one's
palette wherever that system was incomplete, and a token already reported under
unknown_refs would render as though it were fine. Undeclared references are now
blanked on the probe first, so they resolve to nothing, which is what the record
says they are.

Migration 0075 absorbs ui_design_system_id alongside design_rulebook_id rather
than an 0076 undoing it: 0075 has not run anywhere, since dev is unmerged and
deploys come from main. Both keys named a design source for the running
install, and a project already carries its own pointer.

This retires the agreement panel shipped yesterday. It asked whether the sheet
was actually loaded and applied — the one question a record cannot answer about
itself — but only ever about the app you are already inside. Nothing replaces
it; recorded in #2430 rather than quietly dropped.

Step 1 of milestone #274.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-04 10:35:52 -04:00