Coherence survey fixes — instructions, read scope, pull telemetry, dedup #99

Merged
bvandeusen merged 6 commits from dev into main 2026-08-06 22:23:34 -04:00
6 Commits
Author SHA1 Message Date
bvandeusen 24d071619b fix(dedup): compare the artefact, not the prose describing it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 25s
The snippet gate was reading the wrong field, and #2464's UI recipes made it
measurable in both directions at once:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three gaps closed.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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