4502f0a1ae3875c8582c3856a5366c673222d865
1587
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4502f0a1ae |
feat(dedup): the create gate's similarity bars are settings (#4385, rule 25)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / Build & push image (push) Successful in 38s
gate_bars(user_id, note_type) resolves the block bar and, for notes and tasks, the overlap floor from kb_gate_* settings, with the old constants as defaults. Fail-open on an unreadable value; a block bar clamps at 0.80 and the overlap floor at 0.70 and never above the bar. Five fields in Settings beside the duplicate-report floors. CLAIM_LEASE stays a constant, with the reason written at it: a per-user lease would make one shared task live to one reader and dead to another. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
baf22179ef |
refactor(frontend): the sweep pane's column wrapper joins sweep-shared.css (#3207)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m34s
CI & Build / Build & push image (push) Successful in 34s
.sweep was the one rule still written identically in both sweep panes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
c787957ddf |
fix(frontend): load sweep-shared.css once from main.ts — a shared <style src> breaks vite build (#3207)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 1m36s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Build & push image (push) Canceled after 35s
plugin-vue throws "Cannot read properties of undefined (reading 'scoped')"
when several SFCs share one src stylesheet. vue-tsc passed, so only the
image job caught it; :dev has not rebuilt since
|
||
|
|
4a93c8b262 |
fix(task-logs): a collaborator who may write a task may log on it (rule 78)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Failing after 24s
create_log filtered on Note.user_id == user_id, a bare owner check, so a collaborator with write access to a shared task was told it did not exist — and, since a log now stamps the claim, could never be seen working it. It now asks can_write_note. Editing and deleting a log still require its author, which is authorship rather than access. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
22b7a928da |
refactor(frontend): derive the sweep-row visual language into sweep-shared.css (#3207)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m32s
CI & Build / Build & push image (push) Failing after 22s
The note sweep, rule sweep, preference drift and rule history panes each restated the same row recipe in a scoped block. It now lives once in assets/sweep-shared.css under `sweep-` names (unprefixed globals would leak into the unrelated scoped .row-title/.lede/.age/.actions elsewhere), imported unscoped beside each pane's own scoped remainder. Moved only what was shared: RuleHistoryPanel takes .sweep-state and keeps its button row head; PreferenceDrift keeps its tiny-type footnote and overrides the action row's layout. Lede and state now use the body-sm token instead of 0.85rem/0.9rem (13px vs 13.6/14.4px) — one value, from the design system. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
952e56ee75 |
feat(tasks): the hand-off — SessionEnd releases a session's claims; the practice is written down (milestone 381 step 4)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m40s
CI & Build / Build & push image (push) Successful in 24s
Release is the mechanical half: scribe_session_end.sh sends the ending
session's id to /api/plugin/release-session, which releases the claims it
held. A tidy-up, not the guarantee (no SessionEnd on a crash; the lease
covers that), and skipped on /clear so SessionStart(clear) can still
hand the claimed work back.
Saying what happened is the half only the model can do. It is stated as a
practice where it is read: the using-scribe skill owns it ("Hand off before
this session's context stops existing", pinned in test_guidance_ownership),
the static context points at it for the wrap-up moment, and add_task_log's
docstring says a log claims the task. _INSTRUCTIONS is untouched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
e46eea3b52 |
feat(tasks): SessionStart reads the claim — a compaction gets its work back (milestone 381 step 3)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m35s
CI & Build / Build & push image (push) Canceled after 45s
The claim now pays rent to the session that set it. The SessionStart hook sends the host's `source` and the session id; the server renders a claim section by source (task_claims.render_claims): - compact / clear: the work this session had claimed, each with its two latest log entries, so a compacted session resumes from the record rather than from a count of open tasks. - startup / clear: other sessions' live claims (may still be running) and in-progress tasks whose claim went quiet (abandoned mid-task). - fork: the same, framed as "two sessions may now hold this". - resume, or no source sent: nothing. The task sidebar shows a live claim as "Being worked" and a dead one on open work as "Went quiet", so the operator sees a session die mid-task. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a585fe0be0 |
fix(backup): a task claim does not travel in a backup (milestone 381)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 48s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / Build & push image (push) Successful in 29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b06b3a1d8a |
feat(tasks): a task can say a session is working it — the claim (milestone 381 step 2)
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 53s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m18s
CI & Build / Build & push image (push) Skipped
CI & Build / Plugin hooks (push) Successful in 14s
status is durable and nothing clears it, so in_progress cannot also mean "someone is on this now". The claim is that second meaning, stored as who and when so it dies on read rather than needing to be cleared. - notes.claimed_by / claimed_at / claim_touched_at / claim_session (0110). - The server stamps it on the write that is the work: reaching in_progress (update or create, via apply_status_transition) and a work log on an open task. done/cancelled/todo release it. Live while touched within CLAIM_LEASE (2h); dead on read past it, with no sweep. - The plugin's PostToolUse hook on update_task/add_task_log binds the harness's session_id (GET /api/plugin/claim-session). It binds only to a live claim the caller holds; it cannot create one. - to_dict carries `claim`. Plugin version minted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b8f543f45a |
fix(tasks): a create that names a status is stamped like an update to it (#3683)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / integration (push) Successful in 55s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / Python tests (push) Successful in 1m34s
CI & Build / Build & push image (push) Successful in 27s
build_note (create_note and create_records) set the status and nothing it implies, so create_task(status="in_progress") wrote a started task with no started_at, and done/cancelled had no completed_at or next recurrence. The transition is now one function, apply_status_transition, called by both paths; tests pin the invariant for every status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f4e9cd429b |
feat(embeddings): every vector records the model whose space it lives in (#4132)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m40s
CI & Build / Build & push image (push) Canceled after 27s
The four embedding tables stamped chunker_version but not the model, and vector(384) is a width, not an identity: a same-width model swap would write a second geometry beside the first with no error. - embedding_model on note/rule/milestone/system embeddings (0109; existing rows stamped with the only model any install has ever run). - Every write stamps EMBEDDING_MODEL; every backfill's "current" test is is_current_stamp(), both halves of calibration_stamp(). - migrate_floor refuses while any row its surface searches is off the live model, before sampling: re-embed, then migrate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
11b286d786 |
test(dedup): a note is gated at its copy band, not the general floor (#4306)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 48s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
fc1c463641 |
feat(dedup): a note or task blocks only as a copy; a close match is surfaced for judgement (#4306)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 58s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Failing after 1m29s
CI & Build / Build & push image (push) Skipped
Measured on the live corpus, the 74 note pairs at or above the old 0.90 bar were almost all distinct siblings — consecutive dev-logs, sub-notes of one design, research parts — and the one clear copy sat at 0.997. The block refused the next dev-log and taught force=true, as #4134 found for rules. - The semantic arm blocks notes and tasks only at >= 0.98. The title block stays; processes keep 0.90 (not measured). - 0.87 to 0.98 comes back as `overlaps` on the create reply, from the same per-chunk searches, with a note that leaves the call to the session: fold in and delete if it is the same record, keep both if a sibling. - create_note, create_task, create_records and start_planning's steps all carry it; a batch names the record each overlap belongs to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
4ae18a9dd9 |
feat(snippets): a snippet has notes; when_to_use is the situation it is ranked on (#4378)
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 34s
A snippet had no field for prose, so what a session learned about one went into when_to_use — the trigger joined onto every chunk it is embedded as. A sweep found write-ups of up to 3 KB there, headings and all. - notes: stored after the code under `## Notes`, parsed back from the body, carried by every path that rebuilds it (update, merge, un-merge). A snippet with no notes composes the body it always did. - create/update_snippet (MCP) take notes and return trigger_advice when when_to_use is long, headed or multi-paragraph. Advice, not a refusal. - Tool docs, the reusing-code skill and the editor hint describe the trigger as the situation and point the explanation at notes. - Editor gains a Notes field; the detail view renders it as markdown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
66e21a6c60 |
refactor(notes): a snippet's and lesson's stored title is its name; the trigger joins it only in the embedded document (milestone 427)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / integration (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m35s
CI & Build / Build & push image (push) Successful in 32s
The title was `subject — trigger` because the stored title WAS the embedded one, and the join is what makes these kinds rank on the situation they apply to (#2485). Every surface that shows a title then showed the trigger too -- menus, lists and search rows ran to kilobytes. - embeddings.document_title(title, note_type, data, body) joins the trigger from `data` (body fallback) at embed time. Idempotent: an un-migrated composed title comes out the same, never doubled. The embed path, the startup backfill and the dedup gate's semantic signal all use it, so the embedded text -- and every vector -- is unchanged. - Writers store the subject: snippet create/update (service, REST, MCP) and lesson_document. Both compose_title helpers are removed. - Readers: dedup takes `data`; the menus strip the embedded title from a passage; list rows project `when_to_use`, which SnippetListView reads. - 0108 rewrites existing rows on an exact `' — ' || <own trigger>` suffix with raw SQL, leaving updated_at alone so the backfill does not re-embed the corpus for identical vectors. Downgrade recomposes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
bb632c4196 |
feat(retrieval): a menu line is a name, its kind and System, and the whole passage that matched (#4364)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 58s
CI & Build / Python tests (push) Successful in 1m36s
CI & Build / Build & push image (push) Successful in 26s
Injected lines rendered a snippet's or lesson's title, which carries its whole trigger by construction (the embedding shape) and ran past 1,500 characters -- again on every `seen` repeat. The passage under a line was cut to 200 chars from the middle, keeping its head (the title again) and losing where the match was. Now, on both the prompt menu and the write-path prior-art menu: - the line shows the record's NAME (snippet data.name / lesson subject), with its kind and System (`[issue (done) · Plugin & hooks]`); - the passage is the whole matched chunk, title prefix stripped, on one line so the blockquote holds; a title-only match hands over the trigger; - a `seen` record is a one-line pointer to what is already in context. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
20227ebb5d |
fix(plugin): recent-context cap counts text, and an empty transcript is not a failure (#4364)
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 45s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 51s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python tests (push) Successful in 1m35s
The trailing newline became a space before the 600-char cut, so the cap kept 599 characters of text; and under pipefail a grep that matched no reply made the helper exit 1. Trim before cutting; return 0 explicitly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
eb00554976 |
fix(plugin): a SessionStart that loads no project says why, and names the first move (#4366)
CI & Build / Python tests (push) Canceled after 23s
CI & Build / integration (push) Canceled after 27s
CI & Build / TypeScript typecheck (push) Canceled after 28s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / Build & push image (push) Canceled after 0s
The context fetch folded a timeout, an HTTP error and a refused key into one sentence that ended "enter_project() as needed" -- read as optional, so a session could start with no recent milestones or open tasks and no way to know what prior work existed. The fetch now names the cause and the elapsed time, retries once (6s) only where a retry can change the answer, and the fallback states enter_project as the first step, with the marker's project id when there is one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
574b27ae74 |
feat(plugin): auto-inject retrieves on the conversation, not the typed words alone (#4364)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m7s
CI & Build / Build & push image (push) Skipped
The prompt hook sent only the operator's message, so a mid-session
follow-up ("yes do that") named nothing a note, lesson or rule could
match. The hook now reads the tail of the last assistant reply from the
transcript and sends it as `ctx`; the notes and rule arms append it to a
short prompt (<= 280 chars), prompt first, capped at 600 chars. No model
tokens: it is embedding input, and the injected menu's budget is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
a01deeb851 |
fix(shapes): the signature basis admits the band true instances score in (#4306)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 57s
CI & Build / Python tests (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 27s
Measured over 459 judged instance rows against every same-family canon: true instances score 1.0 or ~0.795 (a model whose mixin list differs from the canon's), and 0.8 cut the second band entirely. The proposal is read before it is confirmed, so the floor admits it: 0.75 takes every measured true row, at 35 wrong-canon pairings instead of 14. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
312dc9f6f7 |
fix(shapes): the semantic arm proposes at the write-path floor, not a private 0.8 (#4208)
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m34s
CI & Build / Build & push image (push) Successful in 22s
0.8 was sized for proposals nobody reads; every semantic proposal is read by a judge before it is confirmed. Measured, it proposed 0 of 150 while judged instances of a canon score 0.68-0.71 - the write-path hint's own floor asks the same question of the same documents at 0.68. The arm now uses that floor, scans 8 hits instead of 3 (a true instance ranked 4th behind snippets of other language families), and the proposer version bumps to 5 so rows examined under the old floor are read again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
a875a1b2ee |
fix(shapes): a semantic miss is not evidence — the divergence check stops reading it (#4208)
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 53s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python tests (push) Successful in 1m32s
CI & Build / Build & push image (push) Successful in 24s
Measured live: true instances of the service-unit canon score 0.68-0.71 against it at best, helpers 0.66-0.75 against unrelated snippets, and nothing reaches the 0.8 floor. The "conclusive miss" fired for nearly every body and silenced real divergences exactly as it silenced helpers. The stored miss basis, its flag withdrawal and the report plumbing are removed; the floor stays at 0.8 and the new-shapes-first ordering stays. False prompts are answered by judgment (exempt with a reason). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
e2197ac799 |
fix(tests): assert the withdrawn flag on its row, not on a count the skew margin inflates (#4208)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m34s
CI & Build / Build & push image (push) Successful in 14s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
19438cc894 |
fix(shapes): the capped meaning pass reads new shapes first, and its miss withdraws an early flag (#4208)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Failing after 51s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Successful in 21s
Measured on the first live refresh after
|
||
|
|
cef91f4cd4 |
feat(write-path): a UI write is told which design system binds it (#4256)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m43s
CI & Build / Build & push image (push) Successful in 27s
A design system binds like a rule but reached a session only through the session-start block: complete for a session that asks, silent for one writing a component. The write-path hint now carries a design arm. A trigger, not a search: a project has one design system, so the question is answered by the file being UI (.vue, .css, .tsx, ...) in a project that has one. No vectors, no score, no slot from the ranked menu. An index, not the prose: resolved guidance runs to ~8,000 chars (the house style alone), near the hook's whole additionalContext cap. The line names each inherited layer's section headings and inlines a layer short enough to be a line - in practice the leaf's departure. The other arms do not move. A design-only write returns on its own rather than joining the prior-art guard, so the standing-rule arm still runs only where it ran before. Once per session per system, on the hook's existing token-keyed channel (exclude_derive, design:<id>) - no plugin change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
ad4bd101db |
fix(tests): the stale-premise guard keys on the retired conclusion, not the quoted premise (#4134)
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 46s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 28s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
108b12eeb0 |
fix(dedup): a rule or preference create surfaces what it overlaps by meaning (#4134)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / Python tests (push) Failing after 1m16s
CI & Build / Build & push image (push) Skipped
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
find_duplicate_rule was title-only, on the stated premise that rules are not a semantic-retrieval surface - false since rules were embedded. A preference restating a rule under another title passed untouched, and since both kinds share one ranking, the weaker label could arrive alone. find_overlapping_rules queries semantic_search_rules with the rule_document shape, both kinds, in the scope the new record ranks in (global: every rule the caller owns; project: global + that project). All three MCP create doors call it before creating and return overlaps + overlap_note on the reply. It advises rather than blocks, on measurement: across 16 sampled records the nearest DISTINCT neighbour reached 0.853, while a true rewording scored 0.850. No threshold separates the bands, so the floor (0.80) sits below the restatement and the author judges. The stale docstring is corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
22bb6d7a1a |
fix(tests): move #4196's usage guards to the seam they now describe (#4230)
CI & Build / integration (push) Successful in 45s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m34s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 33s
CI 7223 went red on seven tests in test_lesson_usage_surface.py. None of them
caught a regression. Every property they pin still holds; they pinned it at the
old LOCATION: they patched `usage_for_notes` on the door modules and read the
route source for `usage_for_notes(` and `empty_usage()`.
|
||
|
|
62f3a485ad |
fix(usage): one seam attaches the surfaced-vs-opened chip, and the Knowledge browse uses it (#4230)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 52s
CI & Build / Python tests (push) Failing after 1m2s
CI & Build / Build & push image (push) Skipped
`usage_for_notes` is named for notes and works on every note row, yet the chip reached snippets and rules only. Notes had it nowhere. Lessons had it collected and shown nowhere a person could reach, because #4196 taught `/api/lessons` to attach it and `KnowledgeView` — the only lesson list in the UI — browses through `/api/knowledge`, so `listLessons` still has no consumer. The cause was not a missing line. SEVEN call sites carried their own copy of the same few lines: two REST lists, two REST details, two MCP lists, one MCP detail. Each read perfectly well alone, so "which doors attach usage?" had no answer anywhere in the code — the same asymmetry test_system_tagging_door_parity.py records for System tagging (#4249), where whichever door nobody exercised for a kind is the one that never grew the feature. `attach_usage(rows, key="id")` is now that answer, and all seven go through it. A detail payload is a one-row list, so the single-record doors share the seam rather than keeping a second shape beside it. Deliberately NO try/except: the fail-open already lives in `usage_for_notes`, which reports through `_report_failure("readout")` and returns the zero-filled map. Wrapping it again would swallow the REPORT as well as the error, and a silently-swallowed readout failure is exactly #2663 — every counter reading zero in production for weeks while the writes landed fine. `/api/knowledge` now attaches usage, which closes both holes at once: it is how notes, lessons and processes are all browsed. `KnowledgeView` renders the badge on the card footer, looking the advice up per row because the feed is mixed. The advice moves to utils/deadWeight.ts. Canon #3460 says each caller owns its own const, and that held while each caller showed ONE kind; a mixed feed would need five of its own and the next surface another five. The canon's actual invariant — advice is kind-specific and never baked into the badge — is kept: it is still a prop. The three existing callers now read the same table, so the sentence has one home rather than four. Recorded against #3460 so the next reader is not left re-litigating it. `_row_id` rejects bools explicitly: `int(True)` is 1, so a row carrying a flag under the key would be credited with note #1's counts, and a wrong chip is worse than no chip because it reads as a measurement. A row with no usable id is skipped rather than failing the page. Tests pin the PROPERTY, not one route: no door calls the aggregate directly (AST, so a comment naming it is not a false positive), and every door that shows usage reaches the seam. Plus the N+1 guard — one aggregate per page, asserted on await_count, because the per-row version reads more naturally and is invisible in review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
91cde6c3e4 |
fix(shapes): let a measured meaning-miss silence a divergence prompt (#4208)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 52s
CI & Build / Build & push image (push) Successful in 28s
#4204 gave the divergence check a structural gate, which silenced one of the five false prompts it was filed for. The other four are `def` helpers in a directory whose canon is an `async def` service unit. No refinement of `shape_form` reaches them: they differ from #2793's acceptance case — a hand-rolled sync `confirmDanger` where an async confirm helper is canon — only by the JOB they do, and a signature does not carry a job. The proposer's semantic arm already reads bodies per symbol, which is the comparison option 2 asked for and was thought to be missing. What it did not do was record its MISSES: a hit became `proposal_basis = "semantic"`, a miss left the row indistinguishable from one nobody had looked at. So `flag_divergence` could ask the proposer "do you agree this is the canon?" but never "did you check, and is it not?". `_semantic_canon` now reports whether an empty answer is evidence, and `flag_divergence` withholds the prompt when it is. The whole risk is in the negative, so only a conclusive miss is stored. A body too thin to embed, a row the per-refresh cap never reached, an arm that threw, and a result set that came back full — and may therefore have hidden the canon behind the limit — all stay "cannot tell" and still ask the question. That is the discipline `FORM_UNKNOWN` already enforces here: not knowing must make a check quieter, never more confident. `_SEMANTIC_LIMIT` is named for that reason; the number is load-bearing, not a tuning knob. No migration: `proposal_basis` is nullable Text with no CHECK constraint (verified in the model and across alembic/versions), so rule 36 does not bite. Nothing can mistake the miss for a proposal either — every reader keys on `proposed_snippet_id` or `proposal_group`, and `confirm_shape_proposals` requires the id non-NULL before it will confirm anything. `_PROPOSER_VERSION` 3 -> 4, per its own contract: rows remember the ruleset they were examined under, and without the bump no already-examined row would ever acquire a miss. Option 1 (widening `kind`) stays closed, on the merits rather than on cost: bucketing density by exact form takes the async canon out of a sync candidate's denominator and silences #2793's acceptance case by the identical mechanism, one layer down. The reasoning is on #4208. Tests: tests/test_divergence_meaning_gate.py pins the report contract, with the truncation case tested hardest — reading a cut-off as a negative would weaken the guard in proportion to how many snippets the operator has. The end-to-end discrimination is in test_integration_shape_classify.py on deliberately the SAME fixture as #2793's acceptance case, so the two runs differ in exactly one thing: whether the arm claims to have looked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
d49e4ad106 |
refactor(tests): derive the two duplicated test helpers into tests/helpers.py (#4276)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 48s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Successful in 15s
Derive-first, ahead of the tests shape pay-down. An AST pass over all 2628 definitions under `tests/` found exactly three helper bodies duplicated across files. Two are real copies and are consolidated here; the third is not, and is left alone. `need_tools(*tools)` — byte-identical in three hook-test modules, each skipping when `jq`/`awk`/`git` is absent from PATH. Now snippet #4277. The `import shutil` each file carried existed only to serve it and goes with it. `rule_row(rule_id)` — byte-identical in two integration modules, reading a Rule back through a SEPARATE session so the assertion is about what Postgres holds rather than what the writing session's identity map remembers. Now snippet #4278. Its imports are lazy, because `tests/helpers.py` is imported by unit tests that have no database, which is the same reason `plugin_config` defers its service imports. NOT consolidated: `_side(uid, k, d="")` in test_services_plugin_context and test_write_path_trigger. The body is identical but it closes over a module-local `stored` dict, so it is not self-contained and "moving" it would mean inventing a parameter neither call site wants. That is convention plumbing — two tests independently writing the same one-line side_effect — and it is dismissed in the ledger rather than lifted. Worth recording for the next pass: a repeated NAME is not a family. `_row` is defined in five modules and only two of those share a body; the other three (test_list_rows_brief, test_calibration_stamp, test_shape_ledger) build entirely different objects. Grouping by name would have consolidated three things that have nothing in common. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
b47dc97ca8 |
fix(tests): the round-trip guard needs fake_lesson, not fake_note (#4272)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 45s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m34s
CI & Build / Build & push image (push) Successful in 21s
Run 7208 failed 5 of 23 in the new file, all with a `**Learned from:** #1` that no shape asked for. `lesson_sources` falls back to `arose_from_id` when neither the mirror nor the body names a source. `fake_note` leaves that field to MagicMock, which auto-creates it truthy and coerces to 1 — so every no-sources shape recomposed with a provenance line and the round trip failed for a reason with nothing to do with the trigger. `fake_lesson` pins `arose_from_id` to None and its docstring says why; it is also the truthful stand-in, since these are lessons. Worth recording separately: the local harness that cleared this change used a plain stub class, so it was kinder than the real fixture and could not reproduce the truthiness. The stub has been given the same None. A stand-in that is more forgiving than the one the suite uses will verify a change that CI then rejects — note #2109's point, arriving from the other direction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
9883b010b3 |
docs(lessons): give the round-trip guard the measured growth, not a remembered one (#4272)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 45s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m5s
CI & Build / Build & push image (push) Skipped
The module docstring said the defect grew the worst real body by 520 characters over ten edits. That number was recalled, not measured, and it is wrong — as was the 2500 I then got by deriving it on paper instead of running it. Replayed lesson #4207's actual stored shape through the old code: 607 characters per edit, exactly the length of the trigger's paragraphs 2 and 3 with their separators, taking the body from 1291 to 7361 over ten edits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
8de97b3190 |
fix(lessons): strip a lesson's trigger by what was stored, not by matching a line (#4272)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 51s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m3s
CI & Build / Build & push image (push) Skipped
`update_lesson` is field-wise: an edit that touches only `system_ids`, or only the title, passes `insight=None`. The service then derives the insight from the stored body and hands it straight back to `compose_body`, which re-adds the trigger and the provenance line. The derivation and the composition have to be inverses, and they were not. `_strip_composed_lines` filtered out body LINES matching the trigger marker's pattern. That pattern is `re.M`, so it matches one line. `when_to_apply` is free text and is routinely several paragraphs — `compose_body` writes all of it after the marker, so paragraphs 2..n were ordinary lines the filter had no reason to touch. They came back as part of "the insight", the whole trigger was prepended again on top of them, and the record gained (n-1) paragraphs per update. Silent, compounding, and in the half of the document that does the retrieving: a lesson edited a few times ends up stating when it applies several times over and burying the insight below it. No marker could fix this. There is no end-of-trigger marker in the body, and adding one would not repair a row already written without it. What makes an exact strip possible is that the trigger is mirrored in `data[TRIGGER_KEY]`, so the composed head can be rebuilt byte for byte and removed by subtraction rather than matched by pattern. Hence `lesson_insight(note)` rather than `_strip_composed_lines(body)` — it takes the record because the body alone does not say where the trigger ends. The line-wise removal survives as the fallback for a row whose mirror is missing, where it is right for a one-paragraph trigger and is the best available without one; a mirror that disagrees with the body falls back rather than cutting into the insight. tests/test_lesson_insight_round_trip.py asserts the property rather than the strip: `lesson_document(what, trigger(n), insight(n), sources(n)) == (n.title, n.body)` across six shapes, and again over ten consecutive no-op edits. Falsified against the old code before committing: 11 of 23 fail on HEAD, the headline one reporting a body grown by 3230 characters over ten edits. The one damaged row in the corpus (#4207, two paragraphs duplicated by a `system_ids` edit) was repaired by hand against its authored text. #4221 is the only other lesson with a multi-paragraph trigger and has never been updated, so its body is intact — its derived `insight` was wrong, its storage was not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
43ed7f4db7 |
fix(access): tag a record as the user doing it, not as its owner (#4249)
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m45s
CI & Build / Build & push image (push) Successful in 26s
`set_record_systems` is not a dumb setter. It runs its own `can_write_note` and then links only the Systems the given user can READ. Four of twenty call sites handed it the record's owner instead of the acting user, which did two quiet things at once: the access check became trivially true, since an owner can always write their own record, and the System filter used the owner's visibility rather than the actor's. On a single-user install neither is observable. With a share it is an editor acting with the owner's reach — the shape rule 47 exists to prevent, and the same reasoning routes/notes.py already spells out for `set_supersedes` two lines away. This is NOT a permission change. Every one of the four sites establishes the caller's write access first: routes/lessons.py and routes/snippets.py call `can_write_note(uid, …)`, mcp/tools/processes.py does the same, and mcp/tools/snippets.py reaches `set_record_systems` only after `update_snippet` has raised PermissionError if the caller may not write. So nobody gains or loses the ability to edit anything. What changes is whose reach the tagging runs with, which is exactly the kind of difference that survives review because every call site reads fine on its own. The four: routes/lessons.py:243 owner_uid -> uid routes/snippets.py:211 owner_uid -> uid mcp/tools/snippets.py:486 note.user_id -> uid mcp/tools/processes.py:196 note.user_id -> uid The last one was written earlier in this same session, an hour before the sweep that found it, with a comment confidently explaining why the owner was correct. That is the argument for the guard rather than for care: the unified stance was known and still got it wrong at the next opportunity. So the guard is the point again. `test_every_tagging_write_acts_as_the_caller` walks every `set_record_systems` call in src/ and asserts the first argument is a bare local named `uid` or `user_id` — an attribute access is a record's owner by construction. Verified against `git show HEAD:` as well as the working tree: clean now, four offenders on the code it replaces. Reads are deliberately untouched. `list_record_systems(owner_uid, …)` gates on reading the NOTE, which the caller can do anyway, so it returns the same list either way; it is a different operation and churning it would add noise without changing behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
e2e1ea3667 |
fix(systems): a record can be filed under a System from whichever door wrote it (#4249)
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 29s
A System tag is how `list_system_records` gathers an area's pile, so a
record that cannot be tagged is reachable by search and by nothing else.
`update_lesson` did not take `system_ids` while `create_lesson` did, which
made tagging available exactly once — at the moment of least information.
A lesson is usually written at the end of a piece of work, which is
precisely when that argument gets dropped, and after that the record could
never be filed at all.
Filling in the rest of the table found three more gaps, and the issue's own
generalisation was wrong. It read as "the REST door can do something the
MCP door cannot", on three instances in a row. In fact:
* `create_preference` is the exact MIRROR of the lesson bug — update
takes `system_ids`, create does not. The same capability missing from
the opposite end of the same lifecycle.
* `routes/notes.py` handled `system_ids` NOWHERE, while the MCP door
handled both ends. That runs the opposite way round from the premise.
* `create_process` / `update_process` took it at neither door, though a
process is a note and has always been taggable in the data model.
The real pattern is that whichever door nobody exercised for a kind is the
one that never grew the parameter — which is a better statement of #4248
than the one recorded there, and is not something a reviewer reliably
notices, because each door is only ever read on its own.
Milestones are NOT a fifth gap. `RecordSystem.note_id` is a ForeignKey to
`notes.id` and milestones are their own table, so they cannot be tagged at
any door by construction. Pinned in the test so the next pass does not
re-open it.
So the guard is the point, not the four parameters. `update_lesson` alone
would have left the shape that produced it intact. The new test asserts the
TABLE — every kind taggable anywhere is taggable everywhere it is written —
and keys the registry-coverage check on the SIGNATURE rather than on a
grep, so a module that only names the argument in prose is not swept in and
no hand-kept skip list can go stale. A fifth kind fails there rather than
shipping half-wired, the same reasoning test_derived_mirror_generic_door.py
records for derived mirrors (#3734).
One inconsistency found and deliberately not changed here: for the same
operation `routes/tasks.py` scopes `set_record_systems` by the caller while
`routes/lessons.py` and `routes/snippets.py` scope it by the owner. The new
notes code follows tasks.py and says why in a comment (#47 — an editor-share
holder should tag from what they can see rather than inherit the owner's
reach). Recorded in #4249 rather than fixed as a drive-by.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
|
||
|
|
42360f616c |
fix(telemetry): silence is only an answer where the ledger was watching (#4268)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 31s
`band_hugs_floor` compares an arm's weakest tenth against its floor, and #4225 made it SUSPEND rather than soften when the floor moved inside the window — because across a change the scores and the bar come from two different populations. It reads `floor_moves_since`, which answers "did this arm's floor move", and it read an empty answer as "no, it held steady". Those are the same answer only where the ledger was watching. Before an arm's first floor row there is nothing to move, nothing to report, and no way to tell a steady floor from an unrecorded one. The suspension was reading absence of evidence as evidence of absence, and the symptom is the one #4225 documented: a band "-0.0208 above its floor" — an impossible negative distance, printed with the suspension silent. Every install passes through this. The ledger's first row for an arm is written when that install first boots the release that records baselines, so any window longer than the install is old reaches back past it. The lowered-floor direction hides: the gap comes out comfortably positive and reads as a clean bill of health. `floor_history_gaps(since)` answers the question its companion cannot: which arms' floor history does not REACH the start of the window. Arms come from `surface_names()`, not from the ledger — an arm the ledger has never heard of is exactly the one at risk, so it cannot be the ledger that decides which arms get asked about. Baselines count here, which is the one place the two deliberately disagree. A baseline records a default without changing it, so it is not a move and `floor_moves_since` filters it out. It IS the ledger beginning to observe the arm, and from that moment silence genuinely means the floor held — filtering it out here would suspend the band check forever on every install that has never tuned. `floor_history_unknown` sits between the known move and the band, so an arm with a date to give gives it. Two sentences, because the remedies differ: a date says ask again with a smaller `days`; no history at all says there is nothing to wait for, and names what starts the record. Why now: #4261 measures the work-log change by reading these warnings, and every window for the next month opens before this ledger's first row. Measuring against an instrument that prints a number it cannot support is the #4225 trap one level up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
a58a225d7b |
fix(embeddings): a backfill reads text when it embeds, not when it scanned (#4264)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 58s
CI & Build / Python tests (push) Successful in 1m42s
CI & Build / Build & push image (push) Successful in 32s
Every backfill selected the text alongside the id and then iterated that snapshot, sleeping between records. Over a real corpus that is minutes, and anything edited inside the window was re-embedded by its own write path and then OVERWRITTEN with the pre-edit text the scan had captured — stamped at the current chunker version, so the next boot considered it current and never repaired it. The record stayed findable by what it used to say, indefinitely, until someone happened to edit it again. Nothing reported it, and no existing signal separated it from a correct record: the version was right and the vectors were there. All four backfills had it. Three were pre-existing; I wrote the fourth an hour ago for #4251 by copying the third. #4251 also widened the exposure. The race used to need a note-body edit; now every add_task_log re-embeds its task, so any session recording work during a backfill can hit it — which is exactly what "forward work continues while the backfill runs" means. TWO HALVES, SEPARATE ON PURPOSE. Stopping it: the scan takes IDS ONLY and `_current_row` re-reads each record at the moment it is embedded. One indexed primary-key read, negligible beside the forward pass that follows, and it turns a backfill from "replay a snapshot" into "repair to current truth". The residual race shrinks from the length of the run to the width of one record, which the parent-row claim and the single-transaction replacement narrow further. Repairing it: `_vectors_older_than_their_record` adds a third staleness condition beside "no vectors" and "old chunker" — vectors older than the text they encode. A guard that refuses to write a bad value does not undo the bad values already stored (#4202); the rows are what has to change. That check earns its place beyond the bug that prompted it. It equally catches a record whose `embed_note` never ran — no event loop, a swallowed refresh, a process that died between the commit and the index. Every one of those looks identical from the version column and identical from outside. And a work log is part of a task's document while living in its own table, so writing one never moves `notes.updated_at` and the timestamp comparison cannot see it. `_tasks_logged_since_embedding` covers exactly the records the race was most likely to have hit. The live instance has already run #4251's backfill — the prior-art arm is surfacing `## Work log —` passages, which is the thing itself rather than a banner about it. Whether that run clobbered anything is unknown and was not measured; the repair half picks up whatever it did on the next boot, without needing it diagnosed first. The structural guard in the tests was checked against the old shape, not just the new one: reintroducing `select(Note.id, Note.title, Note.body)` makes it fail, so it can catch the next copy of this rather than only agreeing with today's code (#167). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
1fca8c2808 |
feat(retrieval): a System's charter becomes an answer, not just a filter (#4251)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 58s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / Build & push image (push) Successful in 27s
Step 2 of #4251. A System's `description` is a charter — several hundred words saying what belongs in that area and what does not — and it is the answer to "which part of this codebase does X live in". There was no semantic path to one: `list_systems` enumerates, and `search(system_id=…)` uses a System as a FILTER over notes. So a System could narrow a search and could never be the answer to one, and an agent asking where a record belonged had to read every charter or guess. ITS OWN SEARCH, not a `content_type` over notes, for the reason note 3163 gives about milestones: the row could be shared, the search cannot. A charter competing with the whole note corpus for one top-k is outranked by the records filed under it — the right answer crowded out by its own contents — and "where does this belong?" is a different question from "what prior art is there?", which a caller asking one should not have to read past answers to. So `system_embeddings` (0107) joins note_, rule_ and milestone_embeddings as the fourth sibling, with `system_document`, `upsert_system_embedding`, `semantic_search_systems`, a startup backfill and `search(content_type= "system")`. Scoped like milestones: with a project_id, that project's Systems if the caller can read the project (rule 78); without one, the caller's own. Archived Systems are excluded — an archived area is one the operator has said is no longer where things go, which is exactly the question being asked. `system_document` is the plainest of the four shapes on purpose. A charter is already written as the thing this search has to match, in the words someone asking would use — so there is no trigger to synthesise as `rule_document` must, and no second record to gather as `task_document` must. The stored charter IS the sharp document, the way a snippet's is. `color`, `status` and `order_index` stay out: presentation and bookkeeping, and a vector carrying them would be answering a question nobody asks of a charter. The search publishes `report["best_chunk"]` from the start rather than being retrofitted, which is what #4251 asked of any fourth search. It matters more here than anywhere: a charter runs long and a result shows its NAME, so a match on the paragraph that actually decides where a record belongs would otherwise be previewed by two words that cannot say. The id that comes back is the one `system_id`, `system_ids` and `list_system_records` already take, so the answer to "where does this belong?" is directly usable as "show me what is there" and as "file it here". `embed_system` sits beside `notes.embed_note` at the service for #2056's reason — every door gets it by construction. Not called on delete: that is a soft delete and the search joins through `System`, so the vectors are already unreachable, and leaving them means a restore is findable again immediately. `system_embeddings` is declared in backup's `_NOT_INCLUDED` as derived, beside its three siblings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
aa95c109ea |
feat(retrieval): a task's work logs join the document it is embedded as (#4251)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / Build & push image (push) Successful in 27s
Step 1 of #4251. A work log is the richest prose Scribe holds about WHY something is the way it is — written during the work, recording what was tried and ruled out. #4241 made it readable from the agent's door. It was still not findable, so "has anyone tried this approach?" — precisely the question a log answers — could not reach one. The cost is not hypothetical: #4208 was rebuilt in this session because its logs were unreachable. THE DESIGN QUESTION the issue left open was whether logs embed as part of their task's document or as rows of their own. As their own rows, a hit has to be resolved back to a task to be worth anything, and it needs a fourth search, a fourth result shape and a fourth arm. As part of the task, the objection is that a long log drowns a short title. That objection was true before #280 and is not true now. Chunking made one record into one vector per section, so each log becomes its own title-anchored chunk, scored separately, and the task's own prose keeps the chunk it always had — a task is as findable as its best-matching log rather than as the average of everything in it. The other half is this session's other build: a search hands back the chunk that won (#4243), so a hit earned by a log shows that log's passage under the task's title. Without that a reader would have got body[:240] of the task — the opening of a record whose relevance lives three hundred lines further down. So `task_document(title, body, logs)` sits beside `rule_document`: a synthesised embed-time shape, because the stored record is the task row and the logs live in their own table, so the document that should be searchable exists nowhere until it is built. A task with no logs is returned untouched — most notes are not tasks and most tasks carry no log, and their vectors are the corpus every tuned number here was measured against. CHUNKER_VERSION 1 → 2, and its comment now says what the version actually means. It used to read "whenever chunk_document's output can change for the same input", which this change would slip past: `chunk_document` is untouched and every task with a log now embeds differently while its title, body and the chunker all stand still. The invariant is the document a record is embedded as. The startup backfill re-embeds on that. Create, edit and delete of a log all refresh the task through `embed_note`, the one path every writer shares — an edited log whose vectors still carry its old wording keeps matching what it no longer says. No new kind enters the auto-inject menu: tasks were always in it, and this makes recall on them better rather than changing what the menu spans. The calibration stamp will now report shape_version 2 against numbers measured at 1, which is exactly the report #4104 built it to make. Also promoted `session_returning` into tests/helpers beside `make_mock_session` (#2834) — two files had spelled it out identically and a third was about to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
5fb41af9b0 |
feat(search): the agent's search can ask for every kind the corpus has (#4250)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 58s
CI & Build / Python tests (push) Successful in 1m42s
CI & Build / Build & push image (push) Successful in 28s
The engine took `note_type` and `task_kind` all along. What was missing was a way to say them: the MCP tool's `content_type` knew `note`, `task` and `all`, and `/api/search` knew the same two — so an agent could not ask "has this snippet already been recorded" or "what lessons apply here" without searching everything and reading past the rest. Browse offered nine kinds from the same data. The cause is that each door kept its own map. `_FACETS` in services/knowledge is where a kind is declared, and #3161 made adding one a single edit by generating the SQL filter, the Python predicate and the door's validation from it — but the two search doors were written before that and never joined. So this adds the third dialect, `search_filters_for`, and one composition over it, `content_type_filters`, and both doors now derive instead of listing. Two names keep a meaning of their own, and the docstrings say so: `all` is no filter, and `note` is BROAD — any non-task, snippets and lessons included — where the browse facet of the same name is narrow (`note_type == 'note'`). They are left different deliberately; narrowing this one would stop returning snippets to every caller that already asks this way. An unrecognised kind is now refused rather than answered. Both doors used to fall through: the MCP tool into a filter matching no row, the route into no filter at all, so `?content_type=snippets` returned the whole corpus while looking like a narrowed search. An empty result set is a claim — "the corpus holds nothing like this" — and an agent acts on that claim by building the thing it could not find, so a typo must not be able to make it. The docstring is the agent-facing contract (#2846), and a test now holds it to the table: every kind `_FACETS` declares has to appear in it, because a filter an agent has not been told about is unreachable however well it is wired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
0ec499d9b4 |
test: the milestone result shape moved, and its fake had a mock for a body
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 45s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m45s
CI & Build / Build & push image (push) Successful in 36s
test_milestone_search_is_its_own_shape asserts the result dict exactly, so adding `matched` / `matched_is` / `body_length` broke it — which is the test doing its job. Its fake was a bare MagicMock, so `m.body` autovivified: `matched` came out as a mock object and `body_length` as 0, and nothing in the old assertion touched either. Lesson #2833 is about exactly this, and the failure output is what surfaced it. The fake now carries a real body string and a report with the chunk that matched, so the assertion is about the product rather than about MagicMock's attribute behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
253fb974f3 |
feat(retrieval): every semantic search hands on the passage that matched
CI & Build / Python lint (push) Successful in 8s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Failing after 1m9s
CI & Build / Build & push image (push) Skipped
#4243 fixed one door. Scribe has three semantic searches over three chunk tables, and all three collapsed chunk rows to the best one per record — each of them KNEW which passage earned the hit, and each dropped it. Every surface downstream then previewed the head of the document instead: a span the search had already scored lower, with nothing saying so. Mechanism, one place: - embeddings.record_best_chunk publishes {id: {index, text}} into `report`. Carried in `report`, NOT the return value: all three return list[tuple[float, Record]] and ~30 sites unpack that pair (lesson #4207). - semantic_search_rules and semantic_search_milestones now select chunk_index/chunk_text and publish the winner, as notes already did. semantic_search_milestones gains `report`, which it had no way to take. - services/text.matched_excerpt is the one choice of span, and excerpt_fields the one result block. Doors keep their own field names — the web renders `snippet`, MCP returns `excerpt` — because renaming a field a frontend reads is a different change from fixing what goes in it. Surfaces: - knowledge.query_knowledge, whose own comment calls it "the human's MAIN search surface", was `(note.body or "")[:200]` on every row alike. Now the matched passage on a search, the opening on a browse, and `snippet_is` saying which. KnowledgeView renders that snippet, so this was live. - search(content_type='milestone') gains `matched` — the plan body stays out, but the passage that matched comes along, because recognising a plan means recognising the part you asked about and a description written at the start need not mention it. - The auto-inject menu and the write-path prior-art menu put the passage under their line. Both were title-only, which answers "does this apply?" for a lesson or snippet (the trigger is IN the title) and not at all for an issue or dev-log. No fallback to the body's opening: on a menu that is preamble dressed as a reason, and once indented it cannot be told apart. Left alone deliberately: the rule arms. A rule hint already renders the rule's TRIGGER, which is written to answer exactly "does this apply to me" and beats a matched chunk at it; and that line's budget was measured at #3851. Adding a passage there would duplicate the trigger and spend the budget twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
6abedb0168 |
test: two tests that had to move with the chunk-carrying select (#4243)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 47s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m36s
CI & Build / Build & push image (push) Successful in 30s
test_chunking::test_search_collapses_chunk_rows_to_best_chunk_per_note feeds rows straight into the real semantic_search_notes, so widening the select to carry chunk_index/chunk_text broke its 2-tuple fakes. I had claimed no unit test did this after grepping test_embeddings.py, which was the wrong file. Rows are 4-tuples now, and the test additionally asserts the winning chunk is REPORTED and not merely used for scoring — the property #4243 exists for, and this is where it belongs, beside the collapse it comes from. test_task_work_log_surface's ACL test asserted "task_logs.user_id" was absent from the compiled statement. It is present in every SELECT as a projected column; the claim was about the WHERE clause. Asserts on stmt.whereclause now, which is what was actually meant and still fails if an owner filter returns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
fdc07f2a2b |
fix(search): show the passage that matched, not the opening of the body (#4243)
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m5s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 46s
Raised by the operator: are we limiting what comes back by character count,
and how do we verify the pertinent part is the part displayed?
We were not. mcp/tools/search.py sent (note.body or "")[:240] — a head cut,
with no marker that anything had been removed, so a 240-character preview of
a 4000-character record was indistinguishable from a complete short one.
The opening is the wrong span. The match is semantic and per chunk, and
semantic_search_notes collapses to best-chunk-per-note — its own comment at
the collapse says "the first appearance of a note is its best chunk". So the
system identified the passage that earned the hit and then discarded it:
select(Note, distance) kept no chunk column. A record could rank first on its
sixth paragraph, be previewed by its first, and be judged irrelevant on a
span the search had already scored lower. That biases against long records,
and it is self-concealing — the caller who does not open it never learns the
preview was misleading.
- embeddings: chunk_index/chunk_text ride along in the select, and the
collapse records the winner in report["best_chunk"]. Carried in `report`,
NOT by widening the return tuple: ten callers unpack (score, note) at
~18 sites and nothing would catch the misses (lesson #4207). `report` is
the side-channel this function already uses for best_available_score.
- search(): excerpt / excerpt_is / body_length, and read_full when there is
more. A caller that cannot tell a matched passage from a document opening
cannot judge whether to look deeper, which is the only decision the field
supports.
elide() moves to services/text.py so both callers share one copy, and it
keeps BOTH ends with a stated gap — it is the fallback for when nothing
identifies a better span than "all of it", not the goal.
Also fixes a guard that produced a false failure on the previous commit:
test_pull_telemetry checked `"project_id: int = 0" in body.split("\n")[0]`,
which sees only the first line, so wrapping get_task's signature over four
lines made it report a function that does take the project as one that does
not. Parsed with ast now, and proven to still reject an absent or
wrongly-typed parameter rather than being appeased by reflowing the code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
|
||
|
|
4f2977b848 |
fix(tasks): add_task_log wrote to a surface no agent could read back (#4241)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Failing after 1m14s
CI & Build / Build & push image (push) Skipped
The work log reached the web UI through routes/task_logs.py and nothing
else. get_task returned only the body — a claim written once, before the
work — with the record written during it invisible beside it. So a stale
body arrived with nothing to contradict it, and this session rebuilt work
that had already shipped, with the evidence sitting in the task's own logs.
Read side, scoped through the access layer (rule 78):
- logs_for_task / count_logs_for_task / log_counts_for_tasks in
services/task_logs.py. Scoped by who may read the TASK rather than by
who wrote the entry: list_logs filters TaskLog.user_id == user_id,
which hands a shared collaborator an empty list reading as "no work
has been done". The page query folds readable_notes_clause into the
same statement so the permission does not become an N+1.
- get_task returns work_log; list_tasks and get_milestone steps carry
log_count, zero-filled so "none" is a count and not a missing key.
Elision keeps both ends. The newest entry arrives whole to 4000 chars
because it answers "where does this stand"; older ones are shortened from
the MIDDLE, never the head. A head cut selects what a reader sees by
character position, which is uncorrelated with what matters — an entry
closing with "so this shipped in 04775c3" loses the one sentence that
answers the question, and a truncated flag says something went, never
whether it mattered. The gap states how many characters it covers.
conftest gains an autouse stub for the new read arm, same reasoning as
_no_rule_arm: three widely-called tools grew a database read, and the
existing call sites should not each have to learn about it.
Raised while reviewing this: search() has the same shape and worse —
body[:240] with no marker at all, while the chunk that actually matched
sits unused in the row that won. Filed as #4243, not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
|
||
|
|
f8e53c1c35 |
fix(telemetry): a warning fired on an arm whose decline rate is arithmetic, not evidence (#4232)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m33s
CI & Build / Build & push image (push) Successful in 26s
Found by reading a live `retrieval_telemetry` readout after milestone 419
deployed, not by inspection. The readout said:
cannot_decline / report_preference — "45 calls, 0 of them returned
nothing. An arm that fires unasked has to be able to say nothing; this one
never has. Check that it applies its floor at all."
And printed, beside it, that arm's band: p10 = p50 = p90 = min = max = 0.791.
FIVE IDENTICAL PERCENTILES IS THE TELL. That is not a ranking, it is one
record at one score on every call — because `report_preference` searches a
fixed string (`reply_preferences.COMPLETION_QUERY`, a module constant, and
deliberately so).
For a fixed query against a stable corpus the top score is a CONSTANT, so the
arm's decline rate is 0% or 100% and never in between; which of the two it is
depends only on where the bar sits relative to that one number. "Never
returned nothing" is therefore arithmetic, not evidence, and the warning's own
remedy — check whether it applies a floor — cannot be answered from it.
The arm already knew this about itself; the warning did not:
"a fixed query makes this arm's score a constant and a floor a hair above
it produces a dead arm no amount of traffic will ever reveal"
— services/reply_preferences.py
THIS CLASS OF BUG ALREADY HAS A GUARD, which is the argument for the shape of
the fix. `Point.logs_unconditionally` exists because of #3497: both rule arms
once logged only their hits, so their zero count was structurally 0 and this
same warning would have fired on a LOGGING property while sending the reader
to move a threshold that was never involved. This is that one step over — a
QUERY-SHAPE property — and gets the same treatment: a declared field on
`Point`, and exclusion rather than trust.
AND THE WARNING THAT WOULD BE INFORMATIVE HERE DID NOT EXIST. For a fixed-query
arm the dangerous state is the mirror image: every call empty, meaning the bar
is above the constant and no further traffic will ever move it. The arm is off
rather than quiet, and nothing in the readout said so — `expects_traffic`
covers an arm with NO calls, not one with calls and a 100% decline rate. That
state is real and reached: `report_preference` once logged 69 consecutive
declines at 0.0006 under its bar.
So `fixed_query_never_clears` sends the reader to `near_miss_samples` and not
to the dial — because that incident is also the one where the statistic and
the correct action pointed opposite ways. Every percentile said lower the
floor; opening the refused record showed it was rule 77 arriving as a false
positive, and lowering it would have delivered that rule on every completion
report ever written.
Guards in tests/test_retrieval_warnings.py, including the falsifier that
matters most here: `cannot_decline` must still fire on an arm whose query
varies, or this change is a disabled check wearing a narrowed one's clothes.
Both boundaries tested from both sides, per that module's own standard.
The new code is documented in the `retrieval_telemetry` tool docstring beside
the others (rule 33) — an undocumented code in a readout is a reader meeting a
verdict with no way to disagree with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
|
||
|
|
029692945e |
fix(hooks): the by-name duplicate arm confirms its grep hits against the real extractor (#4227)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / integration (push) Successful in 51s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m48s
CI & Build / Build & push image (push) Successful in 15s
#4222 fixed one end of this defect — the extractors that decide what a payload DEFINES now blank comment and string spans before any line matcher runs. This is the other end: `scribe_local_dups`, which decides which OTHER files already define that name, and which was still a plain `git grep`. A grep sees lines, not spans, so the sentence class with only modifier rules is a deletion that went half-way. — real prose from a module docstring in this repo — matched the arm's pattern for `name=with`. Writing a genuine `change`, `beside` or `wrapped` would be told it already existed, and pointed at a docstring. EVERY HIT IS NOW CONFIRMED by running `scribe_defs` over the candidate file and keeping only names it actually reports. That is the only check that cannot disagree with the other end of the pipe, which is the whole point. MEASURED, NOT ASSUMED — both numbers the task reasoned from turned out wrong. - WHAT IT REMOVES, across 141 payload files of this repo: 15 of 210 report lines. Every one a string literal, a comment, a TypeScript `import { type Foo }`, or Vue's `const emit = defineEmits()` boilerplate. No real definition was lost. Where a name had both — `create_note` — the phantom in a test's `shape_form("async def create_note(...)")` argument dropped out and the definition in services/notes.py stayed. `with` went from four files to none, which is the correct answer: nothing here defines it, and it is a keyword in several of these languages. - WHAT IT COSTS: mean 193ms -> 222ms, worst 569ms -> 572ms. The task feared "over a second added to a PreToolUse hook" from 48 confirmations. It is about 15%, because the arm was already dominated by its twelve `git grep` calls, and because confirmation runs once per DISTINCT candidate file rather than once per (name, file) pair. A deliberately pathological payload — nine names that are ordinary English words — reaches 28 distinct files and 947KB; `scribe_defs` runs at ~33ms per 250KB. THE CANDIDATE CAP IS RAISED FROM FOUR TO TWELVE, and that is load-bearing. Confirmation REMOVES hits, so capping before it runs lets phantom matches crowd a real definition out of the window — hits dropped before anyone looked at them, which is #4042's bug in a new place. The display cap stays at four and now applies to CONFIRMED hits, which is where a cap belongs. #4042's own `|| true` inside the substitution is untouched, and its regression case still passes. The task's own advice not to fix this by tightening the grep pattern is followed and written down: requiring `(` or `{` or `:` after the name rejects `class with only…` and also `class Foo extends Bar {`, `class Foo : Base()` and `type Foo struct {`. This arm exists because it works with no server, no index and no binding (#2280, #2682), which makes a miss here invisible — a visible false positive is the better failure. Guards: tests/test_hook_duplicate_confirmation.py, against real git repos because what is pinned is the interaction between `git grep`, `head` and the extractor. Seven of the eight fail against the previous implementation; the eighth is #4042's regression case, whose job is to keep passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
edbc31f8ca |
feat(lessons): the kind whose whole question is "is this trigger right" was the one kind that could not see its own counts (#4196)
CI & Build / Python lint (push) Successful in 8s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 57s
CI & Build / Python tests (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 36s
The lesson slot has recorded surfaced-vs-opened since it shipped. Nothing showed it. `get_lesson`'s REST door attached `usage` to the payload and no view rendered it; the listing did not attach it at all, and neither MCP door did. #4196 asks when a lesson that keeps getting followed should become a rule, and names the trap in the same breath: raw frequency cannot separate "this should bind" from "this trigger is too broad", and the second is the commoner reading by a wide margin. Surfaced-AND-opened can separate them. Neither question is answerable by a reader who cannot see the numbers, which is why this is the first step and not the threshold. NO THRESHOLD IS PROPOSED HERE, deliberately. The corpus today is 10 lessons with 7 recorded surfacings and 3 opens, over about fifteen hours of usage data. A promotion rule fitted to that would be fitting noise — #3311's failure, and the warning lesson #4228 was written to carry. `UsageBadge` already declines to render a verdict under three surfacings for the same reason. So #4196 stays open: its subject, the promotion path, is still unbuilt. What lands is the evidence it needs. - REST `GET /api/lessons` and MCP `list_lessons` attach `usage` to every row, from one aggregate per page rather than a per-row read, which would be N+1 by construction. Every row carries the key zero-filled, so "never surfaced" is a state a reader can see rather than a missing field they have to interpret. - MCP `get_lesson` attaches it too, and reads it BEFORE recording its own pull. That door records a pull on every open — it has to, or the kind sits permanently at zero — which makes the order load-bearing in a way it is not for a kind that only counts. The REST detail door already ordered it this way; the two now agree about what the number means. - `LessonDetailView` renders `UsageBadge` (snippet #3460) rather than re-spelling the chip, with the advice keyed to this kind: a lesson that is repeatedly offered and never opened is usually keyed to a situation nobody is in, so it points at re-keying `when_to_apply`, not at deleting the claim. Guards, in the two styles this pair of doors already uses: the MCP side driven behaviourally through mocks, including the call ORDER for `get_lesson`; the REST side on structure like its siblings in test_lesson_rest_door.py, because the route is decorated and returns a Quart response. Rule 167's falsifier is included. KNOWN GAP, not fixed here: `KnowledgeView` is the only lesson LIST in the UI and it reads `/knowledge`, not `/lessons` — so the REST listing change reaches `frontend/src/api/lessons.ts::listLessons`, which currently has no consumer. The agent-facing listing does reach a reader today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |
||
|
|
5d06b74599 |
test(backup): prove the three restored code_shapes columns survive real Postgres (#4197)
CI & Build / Python lint (push) Successful in 8s
CI & Build / Plugin hooks (push) Successful in 20s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m39s
CI & Build / Build & push image (push) Successful in 13s
The column guard is a claim about kwargs; "the value comes back" is a different claim, and only a round trip settles it. This drives the real `restore_full_backup` over a judged shape carrying all three of the columns that were going missing. `diverges_from` gets the harder assertion. It is a FK to notes.id, so the tempting fix — carry the exported id across — produces a row pointing at whatever note holds that number in the target database: not dropped, REATTACHED, with the restore reporting success and the divergence about the wrong snippet. So the test asserts WHOSE note the pointer landed on rather than which integer it holds, and it refuses to run at all if the restore happened to reuse the source id, which would let it pass without proving anything. Same shape as the assertion in the rule_usage round trip, for the same seam. Also corrects this module's own docstring, which said the round-trip module "is not written yet". Three existed; this is the fourth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy |