Commit Graph
66 Commits
Author SHA1 Message Date
bvandeusenandClaude Fable 5 9abc4443fb feat(ledger): audit surfaces — list_shapes(compact=True) and classify_shapes_by_rule, the sweep form of a judgment (#2868, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m0s
CI & Build / Build & push image (push) Successful in 26s
The 2026-08 audit judged 3,427 rows in 14 hand-driven batches through a raw
MCP client because a list_shapes page overflowed the tool budget and every
row had to be sent back one by one. Now:

- list_shapes(compact=True): path · symbol · kind · status · signature, plus
  snippet_id / by / proposal / diverges_from / recheck only when set. A full
  500-row page fits. CodeShape.to_compact() is the row shape.
- classify_shapes_by_rule(project_id, path, status, pattern=, kind=,
  snippet_id=, reason=, via=, include_judged=): ONE judgment over every
  unclassified live row under a directory whose symbol matches a glob;
  judged rows are untouched unless include_judged; canonical is refused;
  same gates as the row form; one transaction; returns count + sample.
  shape_ledger.classify_shapes_where / rule_matches (pure) carry it.

Tests: compact row pinned, rule_matches directory/glob/kind semantics, the
tool mount, and an integration sweep (unclassified-only, include_judged,
gates).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:02:55 -04:00
bvandeusenandClaude Fable 5 64c641ce80 refactor(routes): one supersession seam for REST and MCP; PUT/PATCH notes share a handler; shared mask/not-found/caller helpers (#2829, milestone 296 area 5)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Reading the 28 route modules against each other and against the MCP tools:
- routes/notes.py carried a PUT and a PATCH handler that were the same
  function minus the supersedes contract on one of them — one handler now
  serves both verbs, so both carry it.
- The two _attach_supersession copies (REST + MCP) become
  supersession_svc.attach_relations(uid, note_id, data, hint=) — the seam
  the two surfaces must agree through; only the agent surface adds the
  one-sentence reading hint.
- Three local _uid() wrappers over g.user.id → scribe.auth.get_current_user_id
  like every other module; design_systems' private _not_found → routes.utils.
  not_found; the four "********" literals → settings_svc.SECRET_MASK with the
  read/write contract written once.
- routes/plugin.py: the project_id/repo resolution block and the
  comma-separated id parse were copied into three endpoints — _project_scope()
  and _int_list() now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:23:47 -04:00
bvandeusenandClaude Fable 5 c211e12b61 refactor(mcp): one rules_payload() for every surface that hands rules to an agent; drop the dead bearer resolver (#2828, milestone 296 area 4)
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 26s
Reading the 16 tool modules against each other: the six-key applicable-rules
block (applicable_rules, applicable_rules_truncated, subscribed_rulebooks,
project_rules, suppressed_rules, suppressed_topics) was hand-built in five
places — enter_project, get_project, get_task (legacy plans), get_milestone
(three of the six) and services/planning.start_planning. rulebooks_svc.
rules_payload() is now the one place that names them; get_milestone gains the
three it lacked, so every rules-carrying payload reads the same. list_rules /
list_always_on_rules share _rule_summary. mcp/auth.resolve_bearer_to_user_id
duplicated resolve_bearer's parsing and had no product caller (only its own
tests) — removed; the tests now exercise resolve_bearer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:20:36 -04:00
bvandeusenandClaude Fable 5 d74a244b3a feat(ledger): divergence readout — button B where button A is canon, shape history, and judged-shape recheck (#2793, milestone 294 step 7)
CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Every judgment now goes through one helper that remembers the fingerprint
judged (classified_sha) and writes a code_shape_events row; the sync writes
vanished / reappeared / drifted events and flags recheck_at when a body
moves under an instance/variant. The refresh flags diverges_from on shapes
new since the previous computation that sit where one canon dominates the
judged siblings of their directory+kind and were not proposed as that canon
(a first seed flags nothing); the write-path hint asks the same question
in-band for the shapes the hook names. list_shapes(flag=divergence|recheck),
shape_history(project_id, path, symbol) (read-only), coverage line/payload/
card carry divergent + recheck. Backup v8 carries the history. Plugin 0.1.36.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 23:13:39 -04:00
bvandeusenandClaude Fable 5 ba0030e51d feat(ledger): mechanical proposer — every refresh proposes instances against canon and groups derive-first candidates; agents confirm in batches (#2792, milestone 294 step 6)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Failing after 34s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m17s
CI & Build / Build & push image (push) Successful in 48s
Shapes now carry a content fingerprint (signature + whitespace/comment-
insensitive body_sha; migration 0080) and the proposer runs inside the
coverage refresh, the one moment bodies exist: symbol elsewhere → textual
containment → body references the canon → signature resemblance → semantic
(capped per refresh, unreached rows stay unexamined for the next). A hit is
a proposal on the row (proposed_snippet_id/basis/score), never a
classification; rows with no canon hit group by the derive-first rule
(identical body in ≥2 places, same name in ≥3 files) as proposal_basis=
derive + a group key. list_shapes(proposal=any|canon|derive|<basis>) is the
queue; confirm_shape_proposals(project_id, snippet_id|path|basis) confirms
in batches as agent instances; any classify_shapes/hook stamp retires the
proposal. Readout carries proposed + derive_groups (line, payload, card).
Plugin 0.1.35 (skill: the machine proposes, judgment classifies).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 21:30:35 -04:00
bvandeusenandClaude Fable 5 475f0857c9 feat(ledger): write-path stamping — a pulled canon the session then instantiates lands as a hook instance row (#2791, milestone 294 step 5)
CI & Build / Plugin hooks (push) Failing after 2s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Failing after 28s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 39s
The prior-art hook now names the shapes being written (shapes=kind:name —
every definition in the payload, or the one enclosing an Edit found by
walking the file upward) and the server stamps them as instance rows when
the session PULLED a snippet inside PULL_WINDOW that the payload references
by symbol or that the semantic arm scored for this very payload.
classified_by=hook, evidence in reason; never overrides a judgment or a
canonical row, overridable by classify_shapes. Offered-but-unopened stamps
nothing. Pulled-and-already-seen snippets stay in the semantic query as
evidence without re-entering the deduped menu. A brand-new shape gets a
provisional row the next sync confirms or vanishes. Read-scoped keys get
the hint, never the stamp. Plugin 0.1.34.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 20:19:45 -04:00
bvandeusenandClaude Fable 5 e2a084f1fb feat(ledger): coverage self-seeds — enter_project background refresh + refresh_pattern_coverage tool + shape-accounting skill (#2802, milestone 294)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 33s
CI & Build / Build & push image (push) Skipped
The UI Refresh button must not be the only seed path (operator directive,
hit live: the P7 backfill stalled waiting for a click). Three parts:

- enter_project fire-and-forgets refresh_if_stale on the project OWNER —
  absent or day-old readouts recompute in the background (same spawn the
  webhook path uses), the enter stays fast, forge-less owners exit quietly
  (rule #115 baseline), and an in-flight guard keeps concurrent enters from
  fetching the same tarball N times.
- refresh_pattern_coverage(project_id): the synchronous agent-facing form —
  write-gated, owner-keyring resolution, and ValueError messages that name
  the fix (add a connection / bind_repo) instead of measuring nothing
  silently.
- plugin 0.1.33 ships the shape-accounting skill: the five statuses, the
  seed/todo/judge loop, and the derive-first rule, triggered by the
  coverage line or any proved code-to-canon relationship.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 08:18:36 -04:00
bvandeusenandClaude Fable 5 9f1a52a035 feat(ledger): audits write rows, not prose — instruction surfaces carry the classification duty (#2790, milestone 294 step 4)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 27s
verify_snippet docstring: consumers enumerated while checking are
classify_shapes instance rows; detail keeps the WHY and what changed,
nothing that belongs in a row. The MCP _INSTRUCTIONS REUSE bullet gains
"classify shapes against canon — a consumer map is rows, never prose"
(budget rebalanced to 1,998/2,000: the rules bullet lost its historical
push-optimisation clause, the task-note line tightened). The reusing-code
skill gains the full contract: instance/variant/exempt with required
reasons, list_shapes(status=unclassified) as the standing todo, and the
derive-one-first rule for repeating shapes with no canon. Plugin 0.1.32.

The operator-side halves of this step live outside the repo: the Drift
Audit process gained step 7 (classify what the walk proved) and the DRY
Pass s9 now sends consumer maps to the ledger with the note keeping only
the narrative. The Forge P7 backfill payload is enumerated and parked on
task 2790 — it fires after the next deploy + coverage refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 21:16:04 -04:00
bvandeusenandClaude Fable 5 5265d11a6a feat(systems): bootstrap mints directly — the standard vocabulary replaces operator approval (#2798)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 40s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 59s
Operator directive: creating Systems is the agent's call, not an approval
flow — "propose to the operator, create each confirmed one" made the
operator a permission gate. The bootstrap ask now says create_system 3-6
directly, in-session, and the consistency that approval was covering moves
to a standard cross-project vocabulary (CI & Release, Auth & Access, Data
Model & Storage, API Surface, UI & Design, Import & Export, Background
Jobs, Observability): use the standard name verbatim where the area fits,
mint freely beyond it, the duplicate gate guards sprawl. create_system and
enter_project docstrings carry the same contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 20:05:02 -04:00
bvandeusenandClaude Fable 5 942edd1eb5 feat(ledger): classify_shapes + list_shapes MCP tools; get_snippet carries the consumer map (#2789, milestone 294 step 3)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Failing after 42s
CI & Build / Build & push image (push) Skipped
The judgment write path. classify_shapes applies a batch of classifications
to a project's live ledger rows — all-or-nothing (#2709's lesson: the whole
batch is validated, write-ACL'd, and every snippet target proven readable
before any row is touched); rows match by exact (path, symbol), kind narrows,
and shapes no live row matches come back as 'unmatched' rather than errors.
variant/exempt REQUIRE the reason — the why is the record (note 2786) — and
'unclassified' deliberately withdraws a judgment back to the todo. The 'via'
channel is caller-restricted to agent|audit|import; hook and mechanical stay
server-internal so a caller can't launder judgment as machinery.

list_shapes is the todo query (status=unclassified) with composable filters:
path is exact-or-under like recorded locations, snippet_id reads a consumer
map, include_vanished reads history; paged with the true total.

get_snippet now attaches  and  — the structured consumer
map, filtered to projects the CALLER can read so a shared snippet never side-
channels another project's file layout; attached only when non-empty (#2483).

Integration tests pin the batch atomicity, ACL gates, filter composition, the
consumer map on the MCP pull, and the SET NULL companion: a judgment whose
snippet was purged rejoins the todo on the next sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:55:47 -04:00
bvandeusenandClaude Fable 5 9b1597a3c9 feat(ledger): coverage refresh feeds the shape ledger; the readout inverts to accounting (#2788, milestone 294 step 2)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 44s
compute_coverage is now the ledger's sync point: every walk upserts the
extracted shapes (new → unclassified, the todo state; surviving → last-seen
bump; vanished → stamped, kept as history), re-files judgments whose snippet
target went away, and mechanically stamps snippet reference locations as
canonical — the one always-safe rule, self-healing only for its own stamps
(an agent's judgment is never unwound by machinery).

The covering predicate moves to shape_ledger.location_covers as the single
home (match_shapes retired with its consumer); coverage's payload and line
invert from 'N/M shapes recorded' to shape ACCOUNTING per note 2786:
accounted/total with a canonical·instance·variant·exempt breakdown, and
unclassified — THE todo — with its largest directories. Cache key bumps to
v2 so pre-ledger blobs honestly read 'not measured yet' instead of rendering
in a shape no longer spoken.

Readout is deliberately project-wide (all repos' live rows), while the walk
serves whichever repos the owner's keyring reaches this refresh.

Integration tests pin the new contract: rows for every extracted shape,
mechanical canonical stamps carrying snippet ids, idempotent recompute,
agent judgments surviving recompute AND vanish/return, vanished rows leaving
the readout but keeping their history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:37:18 -04:00
bvandeusenandClaude Fable 5 7a5e2b18d9 feat(systems): evidence-carrying bootstrap ask for mature zero-Systems projects (#2683)
CI & Build / Build & push image (push) Successful in 39s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m4s
The generic zero-state systems_hint never converts: identical on every
record, maximal in scope, asked at wrap-up time — Minstrel reached 282
records with zero Systems while vocabularied projects grew organically.
What converts is the project's own evidence at the moment of action.

bootstrap_systems_ask (mcp/tools/systems.py) fires only in a project
with >=20 records and no Systems: it names the record count and recent
titles, and asks for a concrete deliverable — propose 3-6 Systems,
confirm with the operator, create_system the set. Self-retiring: the
first System ends it everywhere. Wired at both moments the task named:
untagged_systems_hint escalates to it at write time, and enter_project
carries it as systems_bootstrap at arrival (attached only when it
applies). Young projects keep the mild question; populated vocabularies
never pay the count query.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 14:53:46 -04:00
bvandeusenandClaude Fable 5 cbccb6bd5d feat(coverage): pattern-library coverage measurement (#2692, milestone 288 step 7)
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 58s
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 / Build & push image (push) Successful in 41s
Server-side shape enumeration per bound repo — one archive download via the
forge adapter, definitions extracted with a Python mirror of the write-path
hook's awk rules (shared test vectors pin the two together) — compared
against recorded snippet locations by path+symbol. Summary is cached in the
settings KV with a freshness stamp; recomputed on webhook push (spawned off
the delivery path) or explicit refresh, never in a request path.

Surfaces: GET/POST /api/projects/<id>/coverage[/refresh], a project-page
card (estimate-labeled, largest-gaps chips), and a one-line evidence-carrying
entry in enter_project read from cache only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 16:05:43 -04:00
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 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 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 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 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
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 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 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 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 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
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 22f907c44d feat(design): offer starter token ROLES at creation, never values
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 46s
CI & Build / Build & push image (push) Skipped
A literal gets written into a stylesheet when there is no role to reach for.
This codebase demonstrated it: the house style had no "text on a filled colour"
role, so 76 call sites wrote a pure-white literal — not out of defiance, but
because nothing existed to write instead. The correction was not a better ban
list; it was declaring the missing role (#2275, #2349).

So the useful moment is creation. A system whose roles are named on day one
never presents the occasion.

Ten groups, ~40 roles: surface, text, action, semantic, border, accent, radius,
space, motion, state. Operator's call was one flat list, every group
individually skippable — presets keyed to app shape (web / CLI / docs) were
rejected because they need the product to hold opinions about app categories,
and a wrong category is worse than a list someone prunes once.

TWO BOUNDARIES THIS HAS TO HOLD, both rule #115:

- The ROLES ship; the VALUES never do. Every seeded token has an empty
  value_by_mode, so a fresh system is a set of named, deliberately-unanswered
  questions. A test asserts no hex appears anywhere in the module — not just
  that tokens are blank, but that no palette hides in a comment waiting to be
  pasted in.
- The PREFIX is the install's. `--fs-` is FabledSword's convention, not the
  product's; the default is a neutral `--ds-` and callers pass their own.

Valueless roles are already legible downstream — render_stylesheet emits them
as commented-out declarations and stylesheet_for_system reports them under
`valueless` (#2299) — so "declared but undecided" reads correctly with nothing
new built.

Both surfaces, per rule #33: MCP gains starter_role_groups/token_prefix plus
list_starter_role_groups(); REST gains the same on POST plus
GET /api/design-systems/starter-roles. The parity enumeration is extended
rather than loosened.

Note create_design_system treats None and [] alike (seed nothing), while
starter_tokens treats None as "all". Deliberate: creation must never write 40
rows into a system whose caller never asked, and the everything-checked default
belongs in the UI where the operator can see it.

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 22:10:09 -04:00
bvandeusenandClaude Opus 5 6eedb0f6b9 fix(instructions): stop mandating a milestone for every non-trivial task
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Failing after 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Python tests (push) Successful in 43s
CI & Build / TypeScript typecheck (push) Successful in 26s
CI & Build / Build & push image (push) Successful in 28s
Four product surfaces told the agent to call start_planning FIRST for any
"non-trivial" work, while a fifth — the milestone bullet four lines up in the
same file — had the criterion right: use one when the work has an arc. The
loudest surface won, so sessions wrapped bug fixes and one-file changes in
milestones that never meant anything.

Mandating one project shape is what rule #115 forbids: some projects are
milestone-shaped, others are a flat task list and always will be.

Now the arc test is stated ONCE in full, in writing-plans, along with what to
do when there is no arc (a task, driven by status and work-logs). The other
surfaces name it and defer:

- writing-plans/SKILL.md gains a "first decide whether this work wants a plan"
  section; its frontmatter trigger is the arc, not "non-trivial"
- using-scribe reflex #4 points at the skill instead of restating it
- server.py's Plan bullet adopts the milestone bullet's own criterion
- server.py's planning paragraph drops from 11 lines to 6: it keeps the claim
  MCP instructions should make (a plan's HOME is a milestone, not a local .md)
  and drops the how, which the skill carries
- start_planning's docstring gains the when

Also removed "call start_planning FIRST — before any brainstorming, design, or
plan-writing skill runs." That was the server asserting priority over the skill
layer. Tools describe what they do; skills decide when they apply.

The structural point outlasts the wording: a surface that restates a rule is a
surface that will eventually contradict it, and nothing checks prose against
prose.

Closes #2322.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 20:39:20 -04:00
bvandeusen 473280e690 chore(design-systems): stop teaching one install's kit in product copy
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 41s
The operator's check: this must be a system for managing design systems, not one
with the FabledSword family built into it.

No LOGIC was coupled — the audit found zero behavioural dependencies. But every
docstring example, every UI placeholder and several comments named this install's
palette, so a stranger creating their first design system was shown
"FabledSword" as the expected shape and `--fs-obsidian` as the expected token.
Examples teach, and these taught the wrong thing.

Placeholders now describe the SHAPE ("Your house style", "--surface-page")
rather than naming one instance's contents, and the token-name placeholder now
says the thing worth saying: name it for its purpose, because `--obsidian` and
`--button-bg` both stop being true the moment the value or the element changes.

Not fixed here, and it is the one real coupling left: DesignView.vue hardcodes
rule 65's button variants and rule 60's type scale as literal arrays, so a
stranger's Design page would display this family's specs. Those arrays exist
because there was no design system to read from — which there now is. They go
when the panel is repointed (#2295), not before.

Scribe's own stylesheet comments ("Moss action-primary per Hybrid") are left
alone: that is the app CONSUMING the family style, which is what dogfooding
looks like, not the tool assuming it.
2026-07-31 10:18:53 -04:00
bvandeusen 23a385e2db revert(design-systems): drop the rulebook import — a migration, not a feature
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 44s
Operator's call, and it corrects a scope error rather than a bug:

  "this is a path for a user to go from a rulebook to a design system. we don't
   need to build this path in the app itself ... you should be the one that does
   the import ... going forward no one else should have to do such a migration."

Right. Nobody starting from a design system will ever go rulebook -> system, so
the whole path was permanent product code serving a single act on one install.
Rule #22: remove it, don't flag it off. Gone from the service, the REST route,
the MCP tool, the UI panel, the API client and its tests.

There is a second consequence I had missed, and it is the better argument. The
parser was WORSE at this than doing it by hand. `propose_tokens` leaves radius
steps and type sizes valueless because "Small 4px" is not a hex and nothing here
parses it — a limitation I documented carefully and shipped anyway. But that
limitation only exists because the importer had to run unattended. Done as work
rather than as a feature, those values are just read and written, and the result
is a complete design system instead of one with a dozen blanks and a count
explaining them.

Scaffolding built around my own absence from the loop, when I am the loop.

KEPT: `extract_expectations` and `design_expectations` in
services/design_rulebook_import.py. The live drift panel still reads them until
it is repointed at a resolved design system (#2295), and removing them now would
take the /design page's only content with it. They go with that change, not this
one.
2026-07-31 09:46:12 -04:00
bvandeusen 0f80b790c7 feat(design-systems): central prose — guidance on the system, rationale on tokens
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Failing after 20s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Skipped
Last piece of the architecture in #2296. The operator: "the prose doesn't have
to live as one offs, there's a central system for managing it."

Two fields, both free-form:

  design_systems.guidance   the narrative a token table cannot hold — aesthetic,
                            voice and tone, what is deliberately out of scope.
  design_tokens.rationale   WHY a token is this value, which is a different
                            question from `purpose` (what it is FOR). "Success
                            equals Moss, aligned by design" is a rationale;
                            "page bg, deepest surface" is a purpose. Rules carry
                            the first routinely and a token row had nowhere to
                            put it.

Free-form rather than a column per category, deliberately. A schema with
`voice`, `aesthetic` and `scope` columns would bake one rulebook's table of
contents into every install (rule #115), leaving the next install three empty
columns and nowhere for what it actually cares about. Both nullable: a design
system with no prose at all is complete, not a draft.

`rationale` cascades like `purpose` — deepest non-empty wins — so an app
overriding a colour keeps the family's reasoning rather than blanking it. Same
argument as `supersedes`: the override was about the value, not the meaning.

In the generated sheet the inline comment prefers `purpose` and falls back to
`rationale`, so a token carrying only the why still says something instead of
rendering bare.
2026-07-30 21:52:16 -04:00
bvandeusen 46d88f9e7e feat(design-systems): check the components against the sheet they claim to use
CI & Build / Python lint (push) Successful in 3s
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 43s
CI & Build / Build & push image (push) Successful in 35s
"The snippets use the tags from the sheet" was a relation nobody could verify.
Now it is three checks, and all three currently fail SILENTLY in this codebase:

  unknown             `var(--x)` where the system declares no `--x`. Renders as
                      nothing at all — no error, no failing test, no visual clue
                      beyond the element quietly not being styled.
  superseded literals a value the sheet said to stop writing, paired with the
                      token to write instead. Only possible because `supersedes`
                      is declared rather than inferred.
  local definitions   custom properties a snippet mints for itself instead of
                      reusing the sheet's — the bloat a shared sheet exists to
                      prevent, where a value stops being reused and starts being
                      restated per component.

The first is not hypothetical. Writing DesignSystemsView.vue earlier in this
same session I used `--color-accent` throughout; it does not exist, and nothing
in the toolchain noticed. This check is the thing that would have.

A token that is both defined and read locally is reported ONCE, as an unknown
reference — "--btn-bg does not exist in the sheet" is the more precise statement
of the same problem, and reporting both would double-count one fact.

Literal matching is boundary-aware and case-insensitive: `#fff` must not fire
inside `#ffffff` (different colours, and a finding on the wrong one sends
someone to change correct code), while `#FFFFFF` in a rulebook has to match
`#ffffff` in a stylesheet — the same trap `normalize_hex` exists for.

Snippets with nothing to report are omitted entirely. A list of everything that
is fine is a list nobody reads twice — the same principle the auto-inject menu
and the drift panel are both built on.

Two integration mistakes fixed while wiring it: `list_snippets` returns
`(rows, total)` and caps its limit at 100, and `get_snippet` returns a Note
model rather than a dict. The list rows carry a preview, not the code, so the
check reads each full body — checking the preview would have reported on a
truncation.
2026-07-30 21:47:47 -04:00
bvandeusen b0a7d9e89b feat(design-systems): the master sheet — purpose tokens, not per-element values
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 33s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 34s
Operator's new requirement (#2299, architecture in #2296): a design system does
not just hold tokens, it generates and manages a master CSS sheet. That settles
the milestone's open "authority mechanism" question — the record is
authoritative because the stylesheet comes out of it.

**The sheet is shaped by purpose and styles no elements.** It declares custom
properties, grouped by what they mean, and contains no `.btn-primary`, no
`table`, no `input`. That is the design, not a shortcut: a sheet that styled
elements would restate the same handful of values once per element and grow with
the UI, where purpose-named values are stated once and reused. Components live
as SNIPPETS that reference these names — a surface that already exists and
already carries prose, locations, drift checks, merge and write-path recall.

A token named after an element (`--fs-button-bg`) is the smell that the two have
been mixed; a purpose name (`--fs-action-primary`) is reused across all of them.

Alongside the CSS the endpoint returns what the text cannot say for itself:
which tokens are still valueless, and which VALUES are declared under more than
one name. The second is the operator's "reuse consistent values" constraint made
checkable — and it reports rather than refuses, because a design system
legitimately aligns colours on purpose ("Success = Moss, by design") and only a
human knows which case it is.

Mode maps to selector the way the codebase already does it: base on the root
selector, every other mode layered on `[data-theme="…"]`. The root selector is a
PARAMETER — #251 recorded that a container-scoped preview cannot use `:root`, so
hardcoding it would have made the generator useless to the preview surface.

A token the rulebook names but states no value for is emitted as a commented-out
declaration IN ITS GROUP rather than dropped. Its absence is the finding, and a
comment puts that finding where the reader already is.

Values are validated, not escaped, and this is a real boundary rather than
tidiness: design systems are shareable records (rule #47), so `red; } body {
display: none` in a system shared with you would otherwise inject CSS into your
page. A value containing `{ } ; @ < >`, a comment delimiter or a newline is
REFUSED and rendered as a comment saying so — rejecting beats stripping, since a
partially-sanitised value is one the operator never wrote and the sheet's whole
claim is that it is the record.

Not in scope, and deliberately: serving this as the app's actual stylesheet.
Generating and exposing a sheet is reversible; swapping theme.css for a
generated one is not, and it should be an explicit call rather than a side
effect.
2026-07-30 21:42:08 -04:00
bvandeusen 4dc57f8ab2 feat(design-systems): import a design system out of a rulebook's prose
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 38s
Milestone #254 step 3 (#2288). Reuses #251's prose extractor as the reader and
adds the part that makes it an import rather than a list of claims.

**The join is the whole trick.** A rulebook states a design system in two places
and neither half is a token: one rule names the colours ("Obsidian #14171A (page
bg, deepest surface)"), another names the custom properties
(`--fs-obsidian/iron/slate`). The import pairs them on the word — `--fs-obsidian`
ends with `obsidian` — which is the only reason it produces something usable
instead of seventy empty names. The parenthetical becomes the token's purpose,
which is the field a bare hex could never carry.

**Prohibitions arrive as replacements, per the operator's reframe.** Rule 52
declares Parchment and forbids pure white in one breath, so the import emits
"write --fs-parchment instead of #ffffff" — the same fact stated forwards. It
attaches to the FIRST token that rule supplied a value for, not to every token
of that rule, because claiming Vellum is also the replacement for white would be
putting words in the rulebook's mouth.

**A token the rulebook names but states no readable value for is still
proposed, with an empty value.** Radius steps and type sizes are prose ("Small
4px") and nothing here parses them; inventing a parse per shape would be
guessing. The name is real and the value needs a human, so the proposal says
exactly that — and the UI leads with the COUNT of those, because an import that
hid them would look more complete than it is.

Preview is the default on both surfaces and in the UI. An import is a proposal:
rulebooks are written aspirationally and some of what they describe was never
built, so every entry carries the rule id and the sentence it came from and a
reviewer can check the claim rather than trust it.

Existing token names are never overwritten. A value already in the record was
put there deliberately — most likely correcting this importer — so a re-run
fills gaps and lists the rest as skipped, which also makes it safe to repeat.

Colours the rulebook names but never exposes as a custom property produce no
token: it never asked for one, and inventing a name would put something in the
record no rule sanctions.
2026-07-30 21:23:32 -04:00
bvandeusen 3da40abcb8 feat(design-systems): declare what to write instead, rather than what not to
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 38s
Milestone #254 step 6, first half (#2295) — and this reframes the task rather
than answering it. The operator's call:

  "in this case we should declare what should be used in place of pure white,
   it's not a prohibition it's what should be used in its place."

None of the three options on the table (a constraints record / the panel reads
both sources / negative token rows) was right, because all three kept the
prohibition as a KIND OF THING. It isn't one. "Pure white is never text" is the
shadow cast by a positive fact — text is Parchment — and a design system that
stores what things ARE has no row for a ban because it never needed one.

So `design_tokens` gains `supersedes`: the literal values this token should be
written instead of. `--color-text-on-action` supersedes `#fff` / `#ffffff`. Same
fact as the rule, stated forwards, and now actionable — a finding can say what
to write rather than only objecting.

It has to be DECLARED, not derived, and that is the crux: `#fff` and Parchment
`#E8E4D8` are different colours, so no value-matching check could ever have
connected them. That mismatch is precisely why the prohibition looked
unrepresentable until it was turned around.

`supersedes` cascades on EMPTINESS rather than on None. A child overriding a
colour says nothing about which literals it replaces, and blanking the family's
declaration there would silently disarm the check for every app that customises
the token — while a child that states its own list replaces it wholesale.

Two things this deliberately does NOT do:

  - It does not feed the drift panel. Superseded literals live in component CSS,
    which `designDrift.ts` cannot see and already documents as a blind spot.
    This is input for the source lint (#2277). Declaring it with nothing
    consuming it yet is honest; wiring it to a panel that cannot check it would
    not be.
  - It does not remove the panel's `prohibited_color` arm yet — that happens
    when the panel is repointed at a resolved system, which needs #2288 first.

The declaration also exposes a missing token: most of the 67 hardcoded
`color: #fff` (#2275) are text on a coloured action button, and the system has
no token for that role at all. Every view hardcodes it. Declaring the token that
was never there is the first real output of the operator's framing.
2026-07-30 21:16:45 -04:00
bvandeusen 143b968c5d feat(design-systems): REST + MCP surfaces, at parity
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 15s
CI & Build / Python tests (push) Successful in 41s
CI & Build / Build & push image (push) Successful in 26s
Milestone #254 step 4 (#2290). Eleven capabilities, both surfaces, one service.

Design systems are owner-scoped top-level records rather than project-scoped
ones, so these do not nest under /api/projects/ the way systems do —
routes/rulebooks.py was the closer shape. The one exception is the project
pointer, which is genuinely about a project: PUT /api/projects/<id>/design-system,
PUT rather than PATCH because clearing it is a first-class outcome and not an
omission.

`/resolved` and `/tokens` are deliberately separate endpoints. One answers "what
does this system CHANGE", the other "what does it end up BEING", and a system
that overrides nothing has an empty token list and a full resolved set. Shipping
only one would have made the other a client-side computation of exactly the kind
the record model exists to remove.

ResolvedToken.to_dict carries the SHADOWED contributions, not just the winner.
Dropping them at the serialisation boundary would have discarded the one thing
step 2 was built to preserve, and it would have been invisible — the payload
still looks complete.

Three sentinel translations on the MCP side, each tested, because an agent
cannot omit an argument and a wrong mapping here is silent:

  - parent_id: 0 = unchanged, -1 = clear (become a family system), positive =
    set. Renaming a system must not silently re-root it.
  - order_index: -1 = unchanged, since 0 is a valid position.
  - value_by_mode: guarded on `is not None`, not truthiness, so `{}` can strip
    every mode from a token instead of being unreachable.

DesignSystemCycle maps to 400 on REST and to a ValueError carrying the message
on MCP — kept apart from 404 throughout. An agent told "not found" retries the
same call; one told what the loop is can fix it.

Two structural guards beyond the parity list: every endpoint must be reachable
on the app (catching a decorator copied without its path, where the second
handler silently never runs), and every public coroutine in the tools module
must be registered (a tool written but never registered is invisible to an
agent, and nothing else would notice).
2026-07-30 17:09:25 -04:00
bvandeusenandClaude Opus 5 6ca215d2b6 fix(telemetry): record a pull on get_task, closing the surfaced→pulled loop
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 43s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build & push image (push) Successful in 39s
Closes #2245. note_usage_events recorded `surfaced` for every auto-inject menu
line regardless of kind, but `pulled` only from get_note, get_snippet and the
REST snippet route. get_task recorded nothing.

Auto-inject ranks kind-blind over a corpus that is overwhelmingly tasks and
issues, so tasks are most of what it surfaces. Measured live, "write a function
to debounce a callback in the frontend" returned three tasks and zero snippets —
all three written as surfaced, none able to record a pull.

surfaced and pulled only mean anything as a PAIR; the rate between them is what
#1038 and #2085 gate on. So the gap sat exactly where the volume is, and the
metric would have said "auto-inject surfaces things nobody opens" for its own
dominant kind — an artifact of the instrumentation, not a fact about the feature,
and one that pointed at a plausible-sounding wrong conclusion.

get_note already carried a comment stating this was meant to cover ANY note kind
precisely so tasks wouldn't look like dead weight. get_task is a separate tool in
a separate module and never got the call — sibling drift, invisible because a
missing side effect changes no return value.

Guarded by a rule-#33 contract test that asserts, by source inspection, that
every getter reachable from an auto-inject menu calls record_pulled. Source
inspection because no behavioural test can see a call that isn't there.

Not fixed here: the REST note/task detail routes still record nothing while the
REST snippet route records `rest_snippet`. That asymmetry is real, but a human
reading a note in a browser is arguably not the same event as an agent recalling
one, and collapsing them could skew the signal the other way. Raised as a
question for the retrieval survey instead of decided in passing.

Pre-fix rows under-count task pulls, one-sidedly by kind — treat them as unknown
rather than zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-30 10:35:23 -04:00
bvandeusenandClaude Opus 5 fe63f3985b feat(snippets): un-merge — reverse one source out of a merged survivor
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 40s
Closes the last of milestone #232. The task said to settle the design
before coding; here is what was settled and why.

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

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

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

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

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

Refs #2165

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

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

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

Two scope decisions worth naming:

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

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

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

Refs #2088

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

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

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

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

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

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

Refs #2086

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

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

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

Deliberate departures from the task as written:

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

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

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

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

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

Refs #2085

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-28 18:09:17 -04:00
bvandeusen dd1b5e5ddb feat(snippets): reverse lookup — find snippets by repo/path/symbol
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Failing after 27s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 1m42s
"What canonical helpers already live in this file?" was unanswerable:
location lived only in the body markdown. It is now a jsonpath containment
query over the `notes.data` mirror added by migration 0070.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-25 23:06:52 -04:00