Commit Graph
42 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 dcd4efcea0 refactor(design): retire /design — a surface that could only inspect itself
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 40s
The design surface is for the projects an install tracks. /design read the
running app's own stylesheet — names out of a bundled theme.css, values out of
getComputedStyle(document.documentElement) — so it could only ever describe the
instance serving the page. Scribe is one project among the projects Scribe
tracks; it gets no view hardcoded into every install.

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

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

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

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

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

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

Step 1 of milestone #274.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-04 10:35:52 -04:00
bvandeusenandClaude Opus 5 5b824c1626 feat(design): the panel now asks whether the app agrees with its own sheet
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 43s
Retiring rulebook #2 left the /design drift panel with no data source, and
because its empty state was well-written the feature read as working while it
could only ever render "nothing designated" (#2419). The original question is
genuinely gone: theme.css is generated from design system 2, so checking the
system against a sheet derived from it would be a tautology.

The question that survives is the one no server can answer. A generated sheet
still has to be LOADED and APPLIED, and nothing checked that it was:

  absent      the record declares a token the app doesn't have — the sheet was
              never regenerated after the record changed, or never loaded
  differs     the app has it with another value — a stale sheet, or a later
              rule that overrode it
  unrecorded  the app declares a token in the record's own family that the
              record has never heard of

Both sides go through the same engine so the comparison is honest: declared
values are set on an offscreen probe and read back, which performs the same
var() substitution the browser already did to the live values. Comparing raw
strings would mark every derived token as drift.

The designation moved with the feature — design_rulebook_id becomes
ui_design_system_id, with a migration deleting the retired key rather than
leaving an inert row. The prose extractor it fed goes too (#2288 said its
runtime role ended when the import landed).

Three orphans of the same shape, found alongside and fixed here:

- darkOverriddenNames hardcoded [data-theme="dark"]. The sheet went dark-first
  months ago, so it matched nothing and the "mode-aware" flag silently left the
  gallery. Now matches the SHAPE of a mode selector, which also holds for an
  install whose modes aren't light and dark.
- groupFor's prefix table never heard of --fs-, so 110 tokens sat under
  "other". Groups now come from the record where there is one; the table can
  only know families that shipped with the product (rule #115).
- The type scale was a hand-written table of nine sizes marked "no token",
  true when written and false since the scale was recorded. Now rendered from
  whatever size tokens the sheet declares, so it can't go stale twice.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-03 11:37:20 -04:00
bvandeusenandClaude Opus 5 be3a0ffaf9 fix(projects): batch the summary queries — the fan-out was exhausting the pool
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 43s
CI & Build / integration (push) Successful in 2m33s
CI & Build / Python tests (push) Successful in 3m1s
CI & Build / Build & push image (push) Successful in 44s
Reported live: Projects and Snippets showed skeletons that never resolved,
/knowledge worked intermittently. The logs named it exactly:

    QueuePool limit of size 5 overflow 10 reached, connection timed out, 30.00
    GET /api/settings  500  30584.0ms
    GET /api/projects  200  30882.9ms

/api/projects was not hanging — it was waiting out the 30-second checkout
timeout and then returning 200 with summaries silently missing, because
_attach swallowed the TimeoutError. Nobody waits 31 seconds, so it read as a
hang.

THE SHAPE: routes/projects.py ran asyncio.gather over every project. Each
_attach called get_project_summary, which opened its own session for three
queries and then called get_project_milestone_summary — which opened one more
session PER MILESTONE. So 25 projects asked for roughly 250 concurrent
checkouts against a pool of 15 (SQLAlchemy's default 5 + 10 overflow).

That is why unrelated routes failed too. Snippets and /knowledge were never
broken; they queued behind the burst and inherited its timeout. /api/settings
returning 500 while /api/projects returned 200 is the same cause wearing two
faces.

The comment above the gather said "one backend pass instead of N+1 frontend
calls". It did remove the N+1 from the network — and recreated it against the
connection pool, where it is worse, because the browser had at least been
serialising those calls.

Now: get_project_summaries() does all projects in four queries and one session,
and get_project_milestone_summaries() does all milestones in two. Two sessions
total for the whole page, independent of how many projects exist.

The progress calculation is extracted to _progress_from_counts and shared by
both the batch and single paths, so the cancelled-exclusion rule cannot drift
into two versions that disagree about whether a milestone is finished.

Tests assert the SESSION COUNT, not just the values. An implementation that
returned identical output while opening a session per project would pass a
correctness test and reproduce the outage.

Deliberately NOT done: raising pool_size. It would move the cliff rather than
remove it, and this endpoint now needs two connections regardless of scale.

Closes #2384.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-08-02 19:09:36 -04:00
bvandeusenandClaude Opus 5 5c51e29f26 fix(embeddings): embed in the service, so every caller gets it (#2056)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Build & push image (push) Successful in 29s
A note or task created through MCP was not semantically searchable until the
next restart's backfill ran. Embedding fired at the five REST route handlers
and nowhere else; the MCP tools call the service directly, so they skipped it.

The shape of this bug is the reason to care: it is invisible on an instance
that redeploys constantly (this one does, per rule 46) and permanent on one
that doesn't. Rule 115 — the product has to stand up for the install that
restarts twice a year, not just for the one that restarts hourly.

Moved to services/notes.embed_note(), called from create_note and update_note,
and deleted from all five routes. Every caller — REST, MCP, recurrence,
snippets — now gets it by construction rather than by remembering.

Two things fall out of having one implementation instead of six:

- It uses note.user_id, the OWNER. The routes were inconsistent: some passed
  the caller's uid, some the owner's. On a shared record the caller's id mints
  a second embedding row that nothing reads.
- services/snippets.py's _embed_snippet existed only because snippets are
  created via MCP and the routes couldn't cover them. Every one of its four
  call sites goes through notes_svc, so the helper and its four calls are gone,
  along with the eight test patches that existed to neutralise it.

RuntimeError (no running loop — unit tests, scripts) and any indexing failure
are both swallowed: a write that succeeded must not be failed by its index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-31 22:46:57 -04:00
bvandeusen 23a385e2db revert(design-systems): drop the rulebook import — a migration, not a feature
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 44s
Operator's call, and it corrects a scope error rather than a bug:

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

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

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

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

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

Two fields, both free-form:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things this deliberately does NOT do:

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

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

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

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

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

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

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

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

Two structural guards beyond the parity list: every endpoint must be reachable
on the app (catching a decorator copied without its path, where the second
handler silently never runs), and every public coroutine in the tools module
must be registered (a tool written but never registered is invisible to an
agent, and nothing else would notice).
2026-07-30 17:09:25 -04:00
bvandeusen 03b3998585 feat(design-systems): the model, the parent chain, and the guard on it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / Python tests (push) Successful in 42s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 20s
CI & Build / Build & push image (push) Successful in 27s
Milestone #254 step 1 (#2286). A design system becomes a record Scribe holds
rather than prose in a rulebook: a named set of tokens with an OPTIONAL parent,
so a family system carries the house style and an app system carries only what
it changes. Answering "what does this app alter?" is then `list its tokens` —
nothing to compute.

`parent_id` is the whole model. It replaces both an `always_on` flag (a family
system is one with no parent) and a subscription join table (a project points at
ONE system; the chain supplies the rest) — less schema than the rulebook shape
it mirrors.

Two decisions the task left open, settled here:

- **Token values are JSONB keyed by mode**, not `value_light`/`value_dark`
  columns. The deciding argument was not flexibility, it was ambiguity: in a
  child system an unset mode means "inherit", in a root it means "not
  mode-dependent", and as columns both are NULL and the resolver cannot tell
  them apart. As a map, resolution is `{**parent, **child}` at every level with
  no special case for roots. Against it: queryability — but nothing filters
  tokens by value in SQL, so that buys a query no caller makes.
- **`group_name` is free text, no CHECK enum.** Groupings are each design
  system's own vocabulary; a whitelist would bake one install's kit into the
  schema. No CHECK is introduced anywhere, so rule #36 does not fire.

The cascade lives in `services/design_cascade.py` as pure functions over a
`{id: parent_id}` map, importing nothing — which is what lets both the service
and `access.py` use it without a cycle, and lets a test state a whole hierarchy
in one literal. Cycles are refused on WRITE by walking up from the proposed
parent (the cheap direction), and survived on READ by a visited-set, because a
loop from a direct DB edit must truncate rather than hang.

ACL (rule #78) is deliberately asymmetric: owning a system grants write,
reaching one through a project you can see grants READ ONLY. An editor on a
shared project must not be able to rewrite the family system every other project
in that family resolves through.

Also renames `services/design_system.py` -> `design_rulebook_import.py`. It is
the #251 prose extractor, whose role is already scheduled to become a one-shot
importer (#2288), and leaving it one character away from the new
`design_systems.py` was a trap for every later session.

Rule #115 throughout: nothing seeds a system or implies a default. An install
with zero design systems is ordinary, not degraded.
2026-07-30 16:54:41 -04:00
bvandeusenandClaude Opus 5 d3ee24f239 feat(design-explorer): rulebook binding + prose→claims extraction
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 27s
Milestone #251 step 2 (#2259). The half of the drift panel that needed to be
testable, which is why it is Python: the frontend has no test runner, so the
fiddly extraction lives server-side and the browser only does set arithmetic
over live token values.

BINDING. A per-user setting `design_rulebook_id` names the rulebook that
describes this install's design system. A setting rather than a column: no
migration, discoverable in the Settings UI (rule #25), and honest about being a
per-install choice rather than a property of the rulebook. No rulebook
designated returns an empty set with rulebook_id: null — the NORMAL case for any
install but the one that set it up (rule #115), which the client renders as an
explanatory empty state rather than an error. The id comes back alongside the
list so "not designated" and "designated but empty" stay distinguishable.

EXTRACTION. No NLP. Rule statements are prose written for humans and should stay
that way, so this takes only what is unambiguous in any prose — the hex colours
and custom-property names a rule mentions. Anything subtler needs a rule author
to opt into a structured form, deliberately left for when someone wants it.

Three things earn their complexity:

- SENTENCE-SCOPED NEGATION. A rule routinely states what the palette requires and
  what it forbids in consecutive sentences ("Parchment #E8E4D8 …, Vellum #C2BFB4
  …. Pure white #FFFFFF is NEVER used."). Detecting negation across the whole
  statement would mark the required colours as forbidden — inverting the finding
  rather than missing it, which is worse. Per sentence, all four come out right.

- HEX NORMALISATION is load-bearing, not tidiness. The rulebook writes #FFFFFF
  and components write #fff; if those don't compare equal the largest drift
  finding in the codebase — 67 hardcoded white text colours (#2275) — reads as
  zero. Alpha forms keep their alpha, since #fff and #ffff are different colours
  and collapsing them would manufacture equality.

- SLASH SHORTHAND. Rulebooks write token families as --fs-radius-sm/md/lg/xl and
  --fs-obsidian/iron/slate/pewter. Both expand under one rule — prefix is
  everything up to and including the LAST hyphen of the first segment — which
  also handles --fs-dur-fast/base/slow. Verified against the real rule text: 18
  tokens from three different shorthand shapes.

how_to_apply is read alongside statement, because rulebooks routinely keep the
statement declarative and put the concrete values in how_to_apply; ignoring it
would miss the checkable half.

Claims dedupe on (kind, value), first source winning, so a colour named by
several rules is one expectation attributed to the rule that introduced it.
Prose with nothing checkable yields nothing — most rules are judgement, not
specification, and a panel that reported unparseable rules as problems would be
unusable.

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

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

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

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

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

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

Refs #2165

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

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

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

Two scope decisions worth naming:

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

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

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

Refs #2088

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

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

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

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

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

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

Refs #2086

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

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

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

Deliberate departures from the task as written:

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

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

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

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

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

Refs #2085

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
2026-07-28 18:09:17 -04:00
bvandeusen e0328f2b1c feat(plugin): write-path trigger — offer prior art before code is rewritten
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 27s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 1m2s
The milestone headline. Auto-inject fires on the operator's prompt; the
moment reuse is actually lost is later, when the agent decides mid-task to
write a helper. A PreToolUse hook on Write|Edit now fires there.

Channel: `additionalContext` with NO permissionDecision, so the note reaches
Claude beside the tool result and the write is never blocked — a recall aid
must not be able to stop the operator's work. Plain stdout would have been
invisible to the model, and deny/ask would have made a nudge into a gate.

Two arms, different in kind:
- BY PLACE — a snippet recorded at this path (or its directory) is prior art
  by definition, not resemblance, so it is neither scored nor thresholded.
  This is what #2083's reverse lookup was built to answer.
- BY MEANING — semantic search restricted to snippets (new `note_type` filter
  on semantic_search_notes) over the code about to be written.
Place ranks first; the top-k cap spans both arms.

Gates carried over from milestone 93 verbatim: threshold, margin, session
dedup, titles-never-bodies. Own `source='write_path'` in retrieval_logs so
precision is tunable separately — the docstring records that the place arm
is unlogged and hands that to #2085.

Its own on/off in Settings but the SAME threshold/top-k: one "how loud may
Scribe be" knob is easier to reason about than two that drift, and splitting
them later is then a data-backed change rather than a guess.

Details worth keeping: the hook sends a REPO-RELATIVE path because that is
how locations are recorded; the git remote resolves to a project and is never
used as the location `repo` filter (different namespaces, would silently
match nothing); the endpoint stays a GET because a read-scoped API key cannot
POST and every other hook depends on that.

plugin.json 0.1.17 -> 0.1.18. Refs #2082, milestone #232.
2026-07-28 09:08:17 -04:00
bvandeusen dd1b5e5ddb feat(snippets): reverse lookup — find snippets by repo/path/symbol
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Failing after 27s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 1m42s
"What canonical helpers already live in this file?" was unanswerable:
location lived only in the body markdown. It is now a jsonpath containment
query over the `notes.data` mirror added by migration 0070.

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

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

Refs #2083, milestone #232.
2026-07-27 23:09:37 -04:00
bvandeusenandClaude Opus 5 9fa474b3c4 fix(mcp): make get_note / get_task share-aware
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 42s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 1m0s
The injected menu tells the agent to open any hit with get_note(id), and
that menu can list a collaborator's record reached through a shared
project. The fetch was still owner-only, so those lines answered
"not found" — for a record the same user opens fine in the browser.
The agent path was strictly narrower than the web path for the same id.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-25 23:06:52 -04:00
bvandeusenandClaude Opus 5 04b58ce01e feat(acl): shared records are search-only and always labelled as someone else's
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 28s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Failing after 41s
CI & Build / Build & push image (push) Has been skipped
Narrows the b7d6fc7 widening per the operator's call, and fixes a regression it
introduced. Decision recorded as note 2094.

Two scopes now, deliberately different:

  readable_notes_clause  — everything the ACL permits, including records reached
                           only via a direct/group note share. For EXPLICIT acts:
                           a search the caller typed, a fetch by id.
  browsable_notes_clause — the caller's own records plus anything in a project
                           they can reach. For PASSIVE surfaces: browse lists,
                           facet counts, the process->skill manifest.

The split is a trust boundary. Anything appearing unasked — in your own list,
your own counts, or as a skill installed on your machine — reads as material you
endorsed. A one-off someone shared with you hasn't earned that standing, so it
waits until you go looking. This also dissolves the shared-Process problem by
construction rather than by special case: the manifest is a passive surface, so a
directly-shared Process is never installed as an auto-surfacing skill.

Regression fix (#2093): b7d6fc7 widened the list queries but left the fetch path
owner-only, so on the MCP path a record could be listed and then not opened —
get_snippet raised not-found, get_process couldn't resolve, and the manifest
emitted stubs whose get_process call would fail. snippets.get_snippet and
notes.resolve_process now resolve the read scope. delete_snippet gained an
explicit can_write_note guard, since being able to SEE a shared snippet must not
imply being able to bin it.

Provenance, so nothing arrives looking like the operator's own work:
- access.describe_provenance / label_shared_items add shared/owner/permission;
  labelling costs no query when everything is the caller's own.
- MCP: get_snippet, list_snippets, get_process and list_processes carry it, and
  get_process now says outright NOT to follow a shared process verbatim — its
  follow-as-written contract was the sharpest instance of the problem.
- The skill stub for a shared Process names its author and asks for a go-ahead,
  instead of describing it as "the operator's saved Scribe process".
- Policy stated once in the MCP _INSTRUCTIONS and the reusing-code skill: a
  shared record is that person's suggestion, weigh it, attribute it, ask before
  adopting it.
- UI: shared snippets show "by <owner>" in the list and a notice above the code
  in the detail view, reusing SharedWithMeView's vocabulary.

Plugin 0.1.15 -> 0.1.16 (skill text changed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-25 22:43:00 -04:00
bvandeusenandClaude Opus 5 b33e2a79c6 fix(snippets): close the recall-surface gaps found reviewing the Drafter
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m7s
Four defects from the 2026-07-25 review of the recall (#227) and merge (#231)
milestones. The theme: a snippet could be recorded but not fully corrected, and
the agent and web surfaces had drifted apart.

- #2076 language was mis-derived from the first caller tag, so a snippet created
  with tags and no language read that tag back as its language — corrupting the
  tag set and the code fence on the next update. Only the FIRST tag can carry
  the language, since compose_tags emits [language, "snippet", *caller].
- #2077 MCP update_snippet mapped "" to "unchanged", so no field could ever be
  cleared and no snippet detached from its project. Now an omitted field is left
  alone, an empty string clears, and project_id follows the -1 = detach
  convention. A service-level UNSET sentinel keeps None available as the clear.
- #2078 surface parity: adds delete_snippet (MCP had none, so a wrong snippet
  could not be retired by the agent that recorded it), locations on MCP create
  and update, system_ids through the REST routes and the editor, and the
  near-duplicate gate on REST create with a "record it anyway" escape.
- #2079 project scoping: list_snippets takes project_id through the service, the
  MCP tool and the REST route, defaulting to every project — reaching across
  projects is the point when the helper you need was written elsewhere.

Sharing the list across owners is deliberately NOT in here: query_knowledge is
shared with the Knowledge browse surface, so widening it changes behaviour well
beyond snippets. Left open on #2079 for a scope decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
2026-07-25 19:00:05 -04:00
bvandeusenandClaude Opus 4.8 7a81b7333e feat(scribe): snippet merge UI — multi-select merge, repeatable locations
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 1m2s
Step 3 of the snippet-merge milestone (#231): the human surfaces for
merge + multi-location, at v1 quality.

Frontend:
- SnippetListView: a Select mode (checkbox on each card) → a sticky action
  bar → a merge modal that lets you pick which selected snippet is the
  canonical (the others fold in and go to trash). Accent border on selected
  cards, Moss action buttons (Hybrid rule).
- SnippetEditorView: the single Location fieldset becomes a repeatable
  locations list (add/remove rows), so editing a merged snippet no longer
  collapses its call sites — no data loss. Sends `locations`.
- SnippetDetailView: renders every location (Location vs Locations label).
- api/snippets.ts: SnippetLocation type, `locations` on fields/input,
  mergeSnippets().

Backend (editor enablement):
- create_snippet service + POST route accept an optional `locations` list;
  PATCH route forwards `locations` — so the editor's location list works
  uniformly on create and edit. Single repo/path/symbol remain the
  one-location shorthand (MCP create contract unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
2026-07-25 17:22:01 -04:00
bvandeusenandClaude Opus 4.8 85625de394 feat(scribe): merge_snippets — unify found one-off snippets into one canonical
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 38s
CI & Build / Build & push image (push) Successful in 1m10s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 59s
Step 2 of the snippet-merge milestone (#231). The dedup gate only PREVENTS
new near-duplicates; merge is the CURE for the ones already scattered.

- services/snippets.py: merge_snippets(user_id, target_id, source_ids) —
  keep the target's scalar fields (name/when_to_use/signature/language/
  code), union the sources' locations + extra tags onto it (so the survivor
  carries every call site as a location), trash the sources (recoverable),
  re-embed the survivor. Pure merge_snippet_fields() factored out for unit
  testing. Returns (survivor_note, merged_ids).
- mcp/tools/snippets.py: merge_snippets(target_id, source_ids) tool (5th),
  and a create_snippet dedup-path nudge toward merge over a forced copy.
- routes/snippets.py: POST /api/snippets/<id>/merge {source_ids} — share-
  aware (can_write target + every source, rule #78) with a same-owner guard
  (cross-owner merge is out of scope).
- plugin reusing-code skill + MCP _INSTRUCTIONS: point found-duplicates at
  merge as the cure (rule #119 surfaces, not a Scribe rule). plugin.json
  0.1.13 -> 0.1.14 in the same change (the #1040 marketplace-ship lesson).
- Tests: pure merge-helper union/dedup; MCP tool (requires a source,
  survivor+merged_ids, not-found); route handler + 5-tool registration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
2026-07-25 17:11:47 -04:00
bvandeusenandClaude Opus 4.8 d257c0fd67 feat(scribe): snippet management UI + REST routes; embed snippets on create
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m4s
Step 6 of the Drafter recall milestone (#227): a human-facing surface for
the reusable-code snippets that agents record via MCP, plus the REST API
behind it. Backend embeds snippets inline on create/update so they're
recallable immediately, not only after a restart.

Backend:
- routes/snippets.py: GET/POST /api/snippets, GET/PATCH/DELETE
  /api/snippets/<id>. Share-aware per rule #78 (get_note_for_user +
  can_write_note), writes performed as the owner; list owner-scoped —
  mirrors routes/notes.py. Registered in app.py.
- services/snippets.py: embed on create/update via a _embed_snippet
  fire-and-forget helper, covering BOTH the MCP tool and the REST route
  by construction. A snippet's value is immediate recall, so it can't wait
  for the startup-only backfill (see issue: MCP create path doesn't embed
  inline for notes/tasks generally).
- tests/test_routes_snippets.py: structural registration + handler/service
  contract + PATCH-field ↔ update_snippet-kwarg parity (rule #33).

Frontend (Vue 3 + TS):
- api/snippets.ts: typed client, modeled on api/systems.ts.
- views: SnippetListView (search, skeleton/empty/error states),
  SnippetDetailView (read + copy-to-clipboard, ConfirmDialog delete),
  SnippetEditorView (create/edit all fields, Ctrl/Cmd+S, Esc, autofocus,
  validation). v1 quality per rules #24/#27.
- router: /snippets, /snippets/new, /snippets/:id, /snippets/:id/edit.
- NoteType union widened to include 'snippet'; Snippets nav link added to
  AppHeader (desktop pill bar + mobile menu).
- Design system: Moss --color-action-primary for action buttons, accent
  --color-primary reserved for tags/brand (Hybrid rule); focus rings;
  JetBrains Mono for code/name/signature/location.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
2026-07-25 14:16:02 -04:00
bvandeusenandClaude Opus 4.8 b49efdcb11 refactor(scribe): retire calendar/events + person/place/list entities (backend)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Failing after 31s
CI & Build / Build & push image (push) Has been skipped
Narrow Scribe to a Claude-Code work system-of-record (milestone #194,
decision note #1759). Wholesale removal per rule #22 — backend + schema half.

Calendar/events + CalDAV: delete models/event, services/{events,caldav,
caldav_sync}, routes/events, mcp/tools/events; strip event branches from
backup (bump v3->v4), dashboard (upcoming_events), trash, recent, and the
mcp server read-only allowlist + instructions.

Typed entities (person/place/list): delete mcp/tools/entities; drop the
notes.metadata (entity_meta) column from model/service/routes and the
knowledge browse service. note_type STAYS — it also marks 'process' notes.

Scheduler: event_scheduler -> recurrence_scheduler, keeping only the
recurring-task spawn job (drops event reminders + CalDAV sync).

Schema: migration 0069 drops the events table + notes.metadata column +
orphan caldav settings rows (faithful downgrade recreates them).

KEEP: recurrence.py (task recurrence), notifications task reminders, graph
view, and every work surface. Frontend + plugin/docs true-up follow next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPtbSzA4JLMAKgFZ8VTg7Q
2026-07-19 13:29:14 -04:00
bvandeusenandClaude Opus 4.8 8126db3203 feat(plugin): knowledge auto-inject (Path A) — title-first per-turn awareness
CI & Build / integration (push) Successful in 12s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / Build & push image (push) Successful in 55s
New UserPromptSubmit hook (scribe_autoinject.sh) + GET /api/plugin/retrieve that
surface the TITLES (never bodies) of the few notes clearing four anti-bloat
gates: a per-user confidence threshold (stricter than pull search), a margin
gate, per-session dedup (exclude_ids), and a top-k ceiling. Each retrieval is
logged to retrieval_logs as source=auto_inject so the threshold can be tuned
from data. Per-user config (enable / threshold / top-k) is DB-backed via
/api/settings with a Settings UI card; defaults enabled, threshold 0.55,
top-k 3 (conservative — tune once auto_inject telemetry accrues).

Scribe: project 2, milestone 93, task 1033.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xz4j1H7pjYSjKsEpgcNH5E
2026-06-22 20:31:07 -04:00
bvandeusenandClaude Opus 4.8 807f478cac feat(search): retrieval telemetry — log every semantic retrieval
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 58s
Add retrieval_logs (migration 0068) + services/retrieval_telemetry with a
fire-and-forget record_retrieval(), wired into the MCP search tool
(source=mcp_search) and the REST search route (source=rest_search). Captures
query, effective params, and the per-result score distribution so KB-injection
thresholds can be tuned from data rather than guessed.

Scribe: project 2, milestone 93, task 1032.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xz4j1H7pjYSjKsEpgcNH5E
2026-06-22 20:10:15 -04:00
bvandeusenandClaude Opus 4.8 96079d5b77 feat(db): table-health readout — per-table bloat metrics in admin card
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 52s
You can't decide what to maintain without seeing what's bloating. Adds a
read-only health panel driven by Postgres' own statistics views.

- services/db_maintenance.py: get_table_health() queries pg_stat_user_tables +
  pg_total_relation_size + pg_database_size — per-table size, live/dead tuples,
  dead-tuple ratio (the bloat signal), and last (auto)vacuum/(auto)analyze.
- routes/admin.py: admin-only GET /api/admin/db-maintenance/health.
- SettingsView.vue: 'Table health' table in the maintenance card, all tables
  sorted by dead tuples, rows >=20% dead-ratio flagged; total DB size shown;
  refreshes after a Run-now so the dead-tuple drop is visible.
- Tests: health row/size shaping + null-timestamp passthrough; route + service
  surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:53:24 -04:00
bvandeusenandClaude Opus 4.8 c4553d937c feat(db): scheduled DB maintenance — daily targeted VACUUM (ANALYZE)
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build & push image (push) Successful in 54s
CI & Build / Python tests (push) Successful in 48s
Adds a daily off-hours VACUUM (ANALYZE) over the high-churn tables the
retention/purge sweeps churn (app_logs, notifications, token tables, notes,
note_versions), on top of Postgres autovacuum, to reclaim bloat left by the
nightly bulk DELETEs and keep planner stats fresh.

- services/db_maintenance.py: run_maintenance() over a closed table allowlist
  via an AUTOCOMMIT connection (VACUUM can't run in a txn); per-table summary
  persisted as the db_maintenance_last_run admin setting.
- services/db_maintenance_scheduler.py: BackgroundScheduler cron (default
  04:00 UTC, after the 03:30 trash purge); enabled-gate checked at fire time;
  live reschedule on hour change. Wired into app.py start/stop.
- routes/admin.py: admin-only GET/PUT /api/admin/db-maintenance + POST /run.
- settings.py: set_admin_setting() (write-side of get_admin_setting) for
  out-of-request writes.
- SettingsView.vue: admin 'Database maintenance' card — enable toggle, run-hour
  (UTC), Run-now, last-run summary.
- Tests: allowlist is closed, VACUUM issued per table, one failure doesn't
  abort the rest, summary persisted; route/scheduler/service surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:42:04 -04:00
bvandeusenandClaude Opus 4.8 33f9a0a4d4 feat(plugin): Phase 4 — Scribe Processes auto-surface as local skills
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Build & push image (push) Successful in 1m3s
#755 Phase 4. Saved Scribe Processes (DRY pass, Drift Audit, …) now surface as
auto-triggered Claude Code skills instead of pull-only get_process calls.

Design correction vs the plan: stubs live in the USER's ~/.claude/skills/, NOT
plugin/skills/_instance/. The plugin is git-cloned and identical per install, so
instance-specific generated files can't ride in it; personal skills are
live-detected within the session (verified via claude-code-guide). MCP prompts
were the alternative but are pull-only (no relevance auto-surface), so skills are
the right primitive.

- backend: GET /api/plugin/processes manifest (services/plugin_context.
  build_process_manifest) — {name, slug, description} per Process; description is
  the auto-surface trigger (title + preview); slugs deduped, blanks skipped.
- plugin: scribe_sync_processes.sh writes ~/.claude/skills/scribe-proc-<slug>/
  SKILL.md (body = "call get_process(name), follow verbatim") and PRUNES stale
  scribe-proc-* stubs. Fail-open + silent; a transient fetch failure never wipes
  existing stubs. Runs as a 2nd SessionStart hook + via the /scribe:sync command.
- plugin.json 0.1.7 → 0.1.8; README updated.
- tests: build_process_manifest (render, slug dedupe, blank-title skip, preview
  truncation). Sync script's write+prune validated in isolation (plugin/** is not
  CI-covered): correct stubs created, stale pruned, unrelated skills untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:00:32 -04:00
bvandeusenandClaude Opus 4.8 f7742173aa chore(plans): make kind=plan retirement consistent across MCP, REST, UI, skills
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build & push image (push) Has been skipped
CI & Build / Python tests (push) Failing after 30s
CI & Build / TypeScript typecheck (push) Successful in 34s
Audit of the plugin + MCP surface after milestone-as-plan (T3): every path
that could still create a kind=plan task or describe the old plan-task model
is now aligned with the hard-retire decision.

- create_task (MCP + REST POST /api/tasks): reject kind=plan with a message
  pointing to start_planning. The 'plan' enum value stays valid so legacy
  plan-tasks remain readable; update paths never touch kind, so they round-trip.
- create_task / get_task docstrings: 'plan' dropped from creatable kinds;
  get_task's rules-augmentation noted as legacy-only (get_milestone for new plans).
- skills/writing-plans: rewritten for milestone-as-plan (body = design, steps =
  child tasks, get_milestone to read back).
- skills/using-scribe: "plans live in milestones via start_planning", not kind=plan.
- TaskEditorView Kind selector: offers Work/Issue; "Plan (legacy)" shown only
  when the loaded task is already kind=plan (display round-trip).
- test: create_task rejects kind=plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:31:51 -04:00
bvandeusenandClaude Opus 4.8 1f6c592226 feat(plans): milestone-as-plan-container; retire kind=plan (T3)
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 59s
The milestone becomes the plan container: a new nullable milestones.body
holds the design/intent (Goal/Approach/Verification) and individual steps
live as first-class child tasks (milestone_id) instead of checkboxes crammed
into one kind=plan task body. start_planning now creates a MILESTONE seeded
with the body template (not a kind=plan task) and returns it with applicable
rules; a new get_milestone MCP tool reads the plan back (body + steps + rules).

kind=plan is hard-retired going forward — start_planning never creates one.
The 'plan' task_kind enum value stays valid so the 11 historical plan-tasks
remain readable in place; no body-shredding backfill (corpus review showed
auto-splitting their checklists into tasks would be lossy: embedded code
blocks, a non-binary [~] state, tables, ID-encoded hierarchy).

- migration 0066: add milestones.body
- model/service/route/MCP: body passthrough on create+update; get_milestone
- server _INSTRUCTIONS: "plan" = milestone w/ body + child step-tasks
- UI: ProjectView shows/edits a milestone's plan body; start_planning expands
  the new milestone and opens its plan editor
- tests updated to the milestone contract + new body/get_milestone coverage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:22:22 -04:00
bvandeusenandClaude Opus 4.8 79040fe5db feat(issues): S4b backend — REST task issue fields + dashboard open-issues
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 59s
Closes the REST gap S4b's UI needs (S2 only extended MCP tools):
- routes/tasks.py: create/update accept system_ids (set-semantics) + arose_from_id;
  GET/create/update return the task's associated systems. kind=issue already
  flowed via task_kind. Associations set via services/systems (ACL-checked;
  can_write_note already gated).
- services/dashboard.py: _open_issues section (owner-scoped, ranked like other
  task lists, capped) added to build_dashboard. Dashboard test updated for the
  new key.

Refs plan 825 (S4b, backend half).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:42:24 -04:00
bvandeusenandClaude Opus 4.8 4f22646c88 feat(issues): S3 REST routes — systems CRUD + project open-issues
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 56s
Third slice of Issues + Systems (spec #825).

routes/systems.py (nested /api/projects/<id>/...): GET/POST systems (list adds
per-system open_issue_count via one grouped query), GET/PATCH/DELETE a system
(GET returns records split into issues/tasks/notes), GET .../systems/<id>/records
(kind/open_only filters), GET .../issues (project's open issues for the project
view + dashboard roll-up). login_required; project access via get_project_for_user;
writes gated by can_write_project (clean 403); system.project_id verified to match
the path. Blueprint registered in app.py.

services/systems.py: + open_issue_counts_by_system (one grouped query) and
list_issues (project issues, open by default).

Tests: structural (blueprint registered + in app, handlers callable, service
contracts take user_id) — matches the house route-test pattern.

Refs plan 825 (S3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:14:54 -04:00
bvandeusenandClaude Opus 4.8 da511fcc9f feat(ui): declutter dashboard done-recently + MCP-access, add per-project stats
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 31s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 58s
Dashboard:
- 'Done recently' chip-cloud -> compact uniform list (Active-now row style),
  showing 5 with inline expand to the rest (backend already returns up to 8).
- New 'Projects' rail card: each active project with 'N open · M done'.
  Backend already computed done_count (dashboard.py) — now surfaced in the
  /api/dashboard payload per active project.

MCP Access (Connect Claude / Claude Code):
- Progressive disclosure: lead with the pre-filled plugin-install snippet;
  fold server name, scope, marketplace URL, and the MCP-only path into a
  single 'Customize' expander. Desktop tab keeps its own server-name field.
- Marketplace URL now defaults to this instance's own repo via
  config.PLUGIN_MARKETPLACE_URL (env-overridable); /api/plugin/marketplace-url
  falls back to it, so the field + install snippet are pre-filled out of the
  box instead of showing a generic placeholder.

Refs #761

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:04:23 -04:00
bvandeusenandClaude Opus 4.8 8fe571e175 feat(plugin): resolve session project from git remote, not a pinned project_id
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 1m0s
The SessionStart hook asked for a project_id via plugin userConfig, which pins
one install to a single project — wrong for an operator working across many
repos/projects. Resolve the active project server-side from the working repo's
git remote instead (a stable identifier, not a dir-name guess).

- repo_bindings table (migration 0064) + RepoBinding model: (user, repo_key) ->
  project, FKs CASCADE.
- services/repo_bindings: normalize_repo_key collapses ssh/https/scp/creds/port/
  .git to host/owner/repo; resolve/set/list/delete.
- GET /api/plugin/context takes ?repo=<remote>; unbound repo -> a "bind this
  repo" hint with a ready bind_repo() call. project_id kept as manual override.
- MCP tools: bind_repo / list_repo_bindings / unbind_repo.
- Hook sends ?repo=$(git remote get-url origin) URL-encoded; all project_id
  handling removed. plugin.json drops the project_id userConfig (0.1.2 -> 0.1.3).
- Tests: normalize equivalence classes + unbound-hint rendering.

Refs task 755 (Scribe-as-plugin push channel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 01:33:58 -04:00
bvandeusenandClaude Opus 4.8 1d82e81527 feat(plugin): admin-configurable marketplace URL as the install default
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 59s
The Settings install command had a <your-scribe-repo> placeholder — not
copyable. Add an instance-global 'plugin_marketplace_url' setting (admin sets
it to the app's own repo) that every user's MCP Access reads, so the
/plugin marketplace add command is copyable out of the box. Keeps it universal
(each deployment configures its own repo) rather than hardcoding one.

- services/settings.get_admin_setting(key): admin-scoped global read.
- routes/plugin: GET /api/plugin/marketplace-url (any user) + PUT (admin).
- SettingsView: Admin → 'Plugin marketplace' field to set it; MCP Access
  marketplace field falls back to the configured value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:17:05 -04:00
bvandeusenandClaude Opus 4.8 3ab16fcbdb feat(plugin): add /api/plugin/context push-channel endpoint + dogfood hook
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 1m6s
Phase 1 of plan #755 (Scribe-as-plugin). Gives Scribe its own session-start
push channel so always-on rules + active-project context surface without being
asked — the gap behind 'I have to prompt for everything'.

- services/plugin_context.build_session_context: renders always-on rule titles
  grouped by topic (under the 10k additionalContext cap; full text stays one
  list_always_on_rules/get_rule call away) + optional project goal/open-task
  count + a recall/update-over-create reflex line. Capped at 9000 chars.
- routes/plugin GET /api/plugin/context (login_required already accepts Bearer
  fmcp_ keys; read scope suffices).
- tests: titles-not-statements, project scoping, length cap (pure mocks).
- scripts/scribe_session_context.sh: dogfood SessionStart hook, fail-open,
  reads url+token from .mcp.json. Superseded in Phase 2 by the plugin-bundled
  hook using userConfig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:43:08 -04:00
bvandeusenandClaude Opus 4.8 b255a0f90e refactor: rename package fabledassistant -> scribe (code-only)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 1m14s
Renames src/fabledassistant -> src/scribe and all imports, plus the
default DB name and DB user/password (fabled -> scribe) in config +
compose. 952 refs / 154 files. Reverses the old 'internal name stays
fabledassistant' convention.

Code-only: live databases are still physically named 'fabledassistant'.
Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the
DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git
host (fabledsword), MCP (fabled-git) and the image name (fabledscribe)
are intentionally unchanged.

ruff check src/ clean locally; CI (typecheck + pytest) is the gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:48:35 -04:00