Compare commits

...
43 Commits
Author SHA1 Message Date
bvandeusen 6b1f5e8031 Merge pull request 'Three fixes where the capability already existed and only the door was missing (#2782, #2975, #2278)' (#130) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 16s
2026-08-24 07:56:44 -04:00
bvandeusenandClaude Fable 5 a8f35e465e refactor(plugin+tests): the last two parallel-family gaps — pageable list tools, one config preamble (#2278)
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 1m34s
CI & Build / Python tests (push) Successful in 2m21s
CI & Build / Plugin hooks (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Build & push image (push) Successful in 25s
DRY pass 3's remainder. Both halves start from enumeration, because the task's
candidate list was hypotheses and the process requires counting before
proposing — and counting changed the answer twice.

## The list_* family: a limit with no offset

Enumerated all 19 `list_*` MCP tools first. They are genuinely heterogeneous —
8 take `project_id`, 6 take `limit`, six take no arguments at all — so a
common-parameter guard would invent a convention the API does not have, which
is the over-DRY trap (§5). One contract IS real: a `limit` without an `offset`
is a truncation with no continuation. The caller is told there are 250 results,
handed 50, and given no way to ask for the rest.

Two tools had it, and both were capped over a service that already accepted an
offset: `snippets_svc.list_snippets(offset=0)` was simply not exposed, and
`list_processes` passed a hardcoded `offset=0` into `query_knowledge`. The
capability existed one layer down in both; only the door was missing — the
missing-sibling shape exactly. Both now expose it.

`tests/test_mcp_list_family.py` guards it, with `list_tags` exempted for a
stated reason (a ranked top-N over a bounded vocabulary has no "rest" to page
into). Candidates derived, decision explicit, same design as test_mcp_auth —
plus the reverse checks: a stale exemption, and an offset with no limit, which
would page through an unbounded result set. Verified non-vacuous by running the
sweep against the pre-fix tree, where it fails naming both tools.

## The verb pairs: no finding, which is the finding

`preview`/`apply` and `dry_run`/`commit` do not exist anywhere in the 102
tools — those were guesses about a shape Scribe never adopted. `count_*` does
not exist either. Of the create/delete stems only `project_rule` lacks a
`delete_X`, and deliberately: a project rule IS a rule, `delete_rule` removes
it, and the docstring says so. `force` sits on 6 of 7 duplicate-gated creates;
the exception is `create_system`, whose gate is an exact normalized-NAME match
rather than a semantic near-match — forcing it would split one area's records
across two piles, which its own message explains. No guard added: it would
need a seven-entry exemption list to defend against a hypothetical. Recorded
on the leave-alone list instead, which the process asks for by name.

## The hook config preamble

Not 3 of 6 hooks as recorded — all FIVE carried their own copy, and of four
lines rather than two. The extra two are a guard treating an unexpanded
`${...}` placeholder as unset, so it is never sent as a garbage Bearer token:
precisely the correctness detail a sixth hook would omit with nothing failing
loudly. Now `scribe_config` in scribe_defs.sh, which also declares the two
names it owns. It sets globals rather than echoing, so a token never passes
through a subshell's output where xtrace or a log could catch it, and returns
a status so a caller can bail (`|| exit 0`) or continue degraded — the
session-context hook still owes its static floor when Scribe is unconfigured.

`check_plugin.py` now runs shellcheck with `-x`. Without it the shared helpers
were invisible: every variable they set read as unassigned and every bug inside
them went unlinted at the call site, which is the opposite of what sharing them
was for. All twelve fail-open scenarios still pass, and all five hooks were
probed live against the instance — prior_art and after_write both still name
canon, autoinject returns context, session_context serves 11k chars of rules,
sync_processes stays silent. Plugin 0.1.46 (#2209).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 01:28:07 -04:00
bvandeusenandClaude Fable 5 64bfa5725f feat(telemetry): a read surface over retrieval_logs — the tuning loop had no read half (#2975)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m3s
CI & Build / Build & push image (push) Successful in 20s
`retrieval_logs` was write-only. `record_retrieval` inserted rows and nothing
in the tree ever selected from them: the only `select()` over RetrievalLog
lived in a test. So #1038's gate — "build the reranker once telemetry shows
precision is the bottleneck" — was unsatisfiable by construction, and the one
real tuning decision on record (the 0.68 write-path threshold, #2223) had to
be reached by hand-probing the live instance with eight payloads. This adds
the half that was missing.

`retrieval_summary(user_id, days=30)` returns two aggregates side by side,
each read from the table built for it — NOT a join. NoteUsageEvent's docstring
is explicit that the two are complements ("RetrievalLog tunes the threshold,
this tunes the corpus") and that RetrievalLog's JSONB `result_ids` cannot be
indexed at the per-note grain, so correlating through it would be both slower
and less honest than reading each source directly. That corrects the approach
sketched on the task.

  - `sources`, per surface: calls, zero_result_calls, cleared_threshold (how
    often the best hit beat the threshold in force for THAT call), the
    top_score spread as p10/p50/p90/min/max, avg_result_count, p90 duration.
    Zero-result calls are counted apart from low-scoring ones — they are a
    different failure and averaging them together would hide both.
  - `usage`, from note_usage_events: ranked surfacings, ambient surfacings,
    and pulls split into `pulled_by_agent` / `pulled_by_human`.

That split is not decoration. NoteUsageEvent's own comment says the mcp_/rest_
prefix is load-bearing and names #1038 while saying so: "is this dead weight?"
is answered by any pull, "was that injected line useful?" only by an agent
pull. `pull_through` exists to answer the second, so it counts agent pulls
over ranked surfacings; both halves ship so the first stays answerable.

Two things the code made me get right rather than guess:

  - Distinct-note counts get their own queries. `count(distinct note_id)` per
    (event, source) group cannot be summed across groups — a note surfaced by
    two sources is one distinct note and would be counted twice. A wrong
    number labelled "distinct" is worse than no number.
  - No CASE in the GROUP BY. #2663 is the bug where a second case() rendered
    its own expanding bind names, Postgres rejected the query, a broad except
    swallowed it, and every counter read zero in production while mocked tests
    passed. Grouping on raw `source` and classifying in Python cannot fail
    that way. For the same reason the readout distinguishes `read_failed` from
    an empty window, and its tests are integration against real Postgres —
    percentile_cont ... WITHIN GROUP only proves it parses against a database.

Exposed as the `retrieval_telemetry` MCP tool, added to `_READ_ONLY_TOOLS`:
it mutates nothing, but its name carries no read prefix, so the completeness
test cannot derive it and it would otherwise have failed closed for read-only
keys in silence — the same reason `enter_project` is spelled out there. Docs
updated to name both exceptions rather than leave the rule looking derivable.

Scoped to the caller's own telemetry: a retrieval log records what one user's
agent asked for, query text included, and is not a shared record kind — the
owner filter is the whole access rule, not a shortcut past access.py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 22:48:54 -04:00
bvandeusenandClaude Fable 5 446d6da0d7 fix(snippets): an annotated record is not a diverged one — a standing verdict vouches at its commit (#2782)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 29s
CI & Build / Python tests (push) Successful in 1m12s
CI & Build / Build & push image (push) Successful in 25s
Pull-time freshness confirms a cached body by containment: normalised cached
code must appear inside the fetched file. That is right for a record kept
verbatim and permanently wrong for a deliberately annotated one. A record
whose job is to say WHY the shape is what it is carries commentary the source
does not, so containment fails on every pull, forever — #2508 was reading
`diverged` although its declarations match the source exactly, and always
would. Annotation is a sanctioned record style, so this was two deliberate
designs colliding, and it was quietly poisoning the one honest drift signal:
decision #2707's scoreboard watches body_freshness for `diverged` and was
accruing false positives it could never age out.

The escape hatch is the verdict itself. verify_snippet is exactly where a
human or agent already judged this body a faithful rendering of that source,
and `verification.commit_sha` records the repo commit they judged it at — a
field whose own docstring (#2688) anticipated this: "makes 'the REPO moved on
since the check' computable, once the forge integration can compare it against
the current head." This is that comparison. When containment fails, a standing
verdict can still vouch, on four conditions and no fewer:

  - the verdict says `ok`;
  - it has not EXPIRED — verification_view recomputes code_sha against the
    record's current body, so editing the record retires the verdict;
  - it was not INVALIDATED by a push touching the location (#2691);
  - the file just fetched is at the very commit the verdict was stamped at.

That last one is what keeps it honest: the hatch vouches for a body against
ONE known commit, never against whatever the source became since. The moment
the file moves, containment resumes as the authority and the record reads
`diverged` until someone re-verifies — correct, because at that point nobody
has looked. The first three are checked by reusing verification_view rather
than restating its rule, so "expired" keeps meaning one thing.

Nothing is rewritten and no new freshness value is minted; `verification`
already travels in the same payload, so a reader can see the basis rather
than take "current" on faith. Verdicts predating commit stamping carry no
commit to compare and therefore do not vouch — they fall through to
containment rather than passing on age alone.

Tests pin the fix and, more usefully, every condition that switches it back
off: moved commit, expired verdict, push-invalidated verdict, non-ok verdict,
no verdict, and a pre-#2688 verdict with no commit_sha. Plus a regression
that a verbatim record still takes the containment path untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 22:36:04 -04:00
bvandeusen 136dbc16a6 Merge pull request 'Dead CSS deleted, and the instrument that found it sharpened (#2962, #2970)' (#129) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 26s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 14s
2026-08-23 21:34:17 -04:00
bvandeusenandClaude Fable 5 6871c25445 docs(frontend): the task-body comment no longer points at a class that was deleted (#2962)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 24s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Successful in 35s
`.editor-body` was removed from editor-shared.css in c5faaf3 — nothing used
it once TaskEditorView replaced it — so "Replace .editor-body for task
editor" now names something a reader cannot find. Say what .task-body is and
record that its predecessor is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:32:11 -04:00
bvandeusenandClaude Fable 5 57f6982f56 test(ledger): the transition/prefix references clear the flag end to end; plugin 0.1.45 (#2970)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Build & push image (push) Successful in 14s
Integration cover for the seam the unit tests only meet at either end: a
template that names no class at all (`<transition-group name="toast">`) and
one that builds its class (`` `status-${s}` ``) run through the real
extractor, through resolve_consumers, into `flag="unused-css"` — and the
rules they reach stop being reported, while a rule nothing can reach stays
listed. The prefix credits BOTH status rows, which is the documented
reading: the template does not say which one it built.

The manifest bump is what the last commit owed — it edited the
shape-accounting skill, and the installer compares versions to decide
whether to refresh the cache that actually executes (#2209), so plugin
edits that ship without a bump reach the repo and stop there. CI caught it
on 4323; 0.1.44 -> 0.1.45.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:30:21 -04:00
bvandeusenandClaude Fable 5 df18e897af feat(ledger): the consumer map reads transition names and concatenated prefixes (#2970)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Failing after 10s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 23s
Paying down #2962 measured `flag="unused-css"` against a hand audit and
found it had a permanent false-positive floor: roughly sixty classes it
called unused are alive and always would be, because two ordinary authoring
forms produce names no reader of `class=` attributes can see. A flag whose
list you cannot act on line by line is worse than no flag — act on it and
you delete live UI.

- A transition `name=` IS a class reference. `<Transition name="toast">`
  makes Vue apply `.toast-enter-active` and its siblings at runtime, and
  React's `<CSSTransition classNames="fade">` does the same with a different
  suffix set. Every spelling of the tag is read (`Transition`,
  `TransitionGroup`, `transition-group`), and the emitted suffix set is the
  union of Vue 3, Vue 2 and React: naming a class no rule defines costs
  nothing, since it resolves to no row. A bound `:name` stays unknowable.
- A concatenated name contributes its static head as a PREFIX reference.
  `` `status-${s}` ``, `'pri-' + p` and `class="card-{{ v }}"` all leave a
  head behind once the hole is blanked — and `_CLASS_TOKEN_RE` accepts a
  trailing hyphen, so until now the extractor emitted a junk token
  `"status-"` that matched nothing. It is now `status-*`, and
  resolve_consumers credits every row whose symbol starts with that head,
  each under the same own-file-else-fan-out rule as an exact token. `*`
  cannot occur in a class token, so the marker rides the existing
  dict[str, int] with no schema change. A head shorter than two characters
  says nothing and is dropped.

Crediting every candidate row is the honest reading: the template genuinely
does not say which one it built, and the alternative is reporting live rules
as dead. What the map still cannot see is a name assembled in a script —
`classList.add` — which stays deliberately out of scope; the skill, the
`flag=` docstring and the refresh payload docs all say so now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:27:27 -04:00
bvandeusenandClaude Fable 5 4179f3e560 refactor(frontend): the second sweep — scoped rules that no longer match their own template (#2962)
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 27s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / Build & push image (push) Successful in 34s
The first sweep asked "does this class token appear anywhere outside a
<style> block?". That is too generous for a `<style scoped>` rule, which can
only ever match its own template, the component's root element, or whatever
`:deep()` reaches — so a scoped rule whose name lives only in some OTHER file
is dead regardless. The server's map had this right and my local pass did
not; this closes the gap.

79 scoped classes are absent from their own file. 48 are names their own file
BUILDS — `status-${task.status}`, `pri-${p}`, `toast--${type}`,
`perm-${permission}`, `diff-${op}`, `is-${kind}` — and 12 more are Vue
transition classes. Those are the map's documented blind spot and they stay.
The remaining 19 match nothing:

- AppHeader (9, -39 lines): the whole connection-status indicator —
  .status-indicator/.status-dot/.status-text and the five colour states,
  plus @keyframes pulse-dot and status-pulse, which had no other user. Same
  Phase 7 residue as the last commit. `.btn-icon.active` goes too: `active`
  is never applied in this component (its nav uses router-link-active).
- SettingsView (6): .status-badge/.status-on/.status-off,
  .perm-granted/.perm-denied, .location-row. This view renders no child
  components at all, so nothing can inherit its scope.
- WorkspaceNoteEditor: .note-row:hover .btn-delete and .btn-suggest-tags —
  those buttons are .btn-danger-outline/.btn-ghost now.
- ProjectListView .loading-msg; SnippetEditorView .field-row.three, whose
  media-query companion keeps its live .field-row half.

Checked against child-component roots before deleting, since a child's root
element does inherit the parent's scope id — none of the 19 is one. Template
and script regions byte-identical; both style checks still pass with only
the six pre-existing reports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:21:43 -04:00
bvandeusenandClaude Fable 5 c5faaf38fb refactor(frontend): delete the dead CSS the consumer map surfaced — 102 classes, 588 lines (#2962)
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 10s
CI & Build / integration (push) Successful in 26s
CI & Build / Build & push image (push) Successful in 36s
Milestone 302's map flagged 195 css rows no template names. Re-derived the
list locally with a stricter test than the map's — a class is dead only if
its token appears NOWHERE outside a <style> block, in markup or script, and
is not the static half of a concatenated name (`priority-${p}`) — which
takes the map's known blind spot off the table. 123 survived that; 21 of
those are Vue <Transition>/<transition-group> classes generated at runtime
from a name= attribute (detail-fade, peek-slide, shortcuts-fade, tab-fade,
toast) and one is ProseMirror's vendor class. Those stay. The other 102 go.

- SettingsView.vue (80, -417 lines): whole features whose UI was removed —
  Ollama model management (model-*, pull-bar-*, suggestion-chip), push
  notifications (push-*), the voice library and voice blending (voice-*,
  blend-*), geo status (geo-*), MCP package rows, retention, the learned
  summary, and the form furniture that served them.
- editor-shared.css (16, -151): the standalone assist panel, superseded by
  the sidebar assist section — streaming now renders as .stream-preview in
  the main area, so .assist-panel*, .assist-sections*, .assist-preview-box
  and .typing-indicator have no markup left. @keyframes blink went with the
  last rule that animated it. .editor-body/.editor-main are dead too:
  TaskEditorView replaced them with .task-body/.task-main.
- theme.css: .btn-new-conv/.btn-send dropped from the touch-target list
  (names from another app), and the .hide-desktop utility no one used. The
  generated --fs-* token block is untouched.
- ShareDialog .user-result-email, KnowledgeView .today-link,
  ProjectView .edit-input (the remainder rule keeps its rationale comment
  and its two live selectors).

A selector dies only when every class in its descendant chain is checked —
`.live .dead` matches nothing either — and a rule only when its whole comma
list is dead, so mixed lists keep their live half. Template and script
regions are byte-identical; check_dangling_styles.py and
check_design_tokens.py both pass, with only the six pre-existing reports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 21:16:06 -04:00
bvandeusen 649fdff2ea Merge pull request 'The CSS pay-down: 72 name families derived or dismissed with the consumer map in hand (milestone 302 step 4)' (#128) from dev into main
CI & Build / Python lint (push) Successful in 6s
CI & Build / Build & push image (push) Successful in 13s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 46s
CI & Build / integration (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m10s
2026-08-23 21:03:33 -04:00
bvandeusenandClaude Fable 5 9190fa0f10 refactor(frontend): the pay-down edits the first script dropped after its assertion stop — SharedWithMe/ProjectView page-header+empty-msg remainders, DesignSystems/Settings field-hint+.input trims, pane form-buttons deletions (milestone 302 step 4)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 20s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Successful in 35s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 17:34:07 -04:00
bvandeusenandClaude Fable 5 6fb0cb38a5 refactor(frontend): CSS pay-down, derive batch (milestone 302 step 4) — page-header, error-msg/state-msg/empty-msg, empty-title/empty-sub, required, field-hint recipes into components.css; form-buttons into rules-shared.css; scoped copies trimmed to remainders/overrides; the three views' .input becomes fs-input + width/box-sizing remainder (canon #2336)
CI & Build / TypeScript typecheck (push) Failing after 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 24s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 17:30:43 -04:00
bvandeusen cba542a3ec Merge pull request 'Hooks say when Scribe didn't answer; the CSS consumer map (milestone 302 steps 1–3)' (#127) from dev into main
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python lint (push) Successful in 10s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m12s
CI & Build / Build & push image (push) Successful in 16s
2026-08-23 14:18:32 -04:00
bvandeusenandClaude Fable 5 c28c87c39e test(ledger): the consumer-map integration test owns its unreferenced rule — the seeded button.css row vanishes under the test's own tree (#2936)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 15s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 14:09:04 -04:00
bvandeusenandClaude Fable 5 8664d8ad14 feat(ledger): the consumer map surfaces — every css row carries used_by on list_shapes, flag="unused-css" (the map's negative space, surfaced never deleted), derive groups and the write-path family carry consumers, the derive line says "used by N template(s)", coverage payload unused_css + the standing block; docs, SKILL, plugin 0.1.44; the two step-2 tests expected 5 edges where fan-out makes 6 (milestone 302 step 3, #2936)
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / integration (push) Failing after 44s
CI & Build / Python tests (push) Successful in 1m26s
CI & Build / Build & push image (push) Successful in 31s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 14:03:35 -04:00
bvandeusenandClaude Fable 5 ffbdf19116 feat(ledger): the CSS consumer map — code_shape_consumers edges (shape → file whose markup names the class, count), migration 0086, resolve_consumers (own-file row when the template defines the class, else every other definition), sync_repo_consumers rebuilt from the archive on every refresh, consumers_of; derived, so not backed up (milestone 302 step 2, #2935)
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Failing after 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 52s
CI & Build / Build & push image (push) Skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:59:55 -04:00
bvandeusenandClaude Fable 5 dffbf43d84 feat(coverage): class_references + scan_archive — each template-bearing file's class tokens (static class=/className=, Vue :class object/array/ternary, React className={…}, Svelte class:x) read in the same tar walk as definitions; the CSS consumer map's extractor (milestone 302 step 1, #2934)
CI & Build / Plugin hooks (push) Failing after 1s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 29s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Successful in 29s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:57:13 -04:00
bvandeusenandClaude Fable 5 31383bcebe test(hooks): the two tests that read a refused connection as "nothing recorded" now answer through a sink, and the silence case removes the shape it had left behind (#2932)
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 16s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 11:05:42 -04:00
bvandeusenandClaude Fable 5 0ab94b2a00 fix(plugin): write-path hooks say when Scribe did not answer — once per outage, shared marker, record nudge withheld on an unanswered call; check_plugin allows exactly that line when unreachable; plugin 0.1.43 (#2932)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 13s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 40s
CI & Build / Python tests (push) Failing after 49s
CI & Build / Build & push image (push) Skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 10:59:54 -04:00
bvandeusenandClaude Fable 5 9c00a4b6e1 fix(plugin): after-write hook waits 8s on the prior-art call — a cold-start round-trip (~4.6s after a redeploy) failed open at 4s and dropped the ledger line on the first write; plugin 0.1.42
CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / integration (push) Successful in 27s
CI & Build / Python tests (push) Successful in 1m9s
CI & Build / Build & push image (push) Successful in 14s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 01:06:08 -04:00
bvandeusen 144192754c Merge pull request 'Derive pay-down + CSS families by name (milestone 299 steps 5–6, note #2917)' (#126) from dev into main
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 26s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 59s
CI & Build / Build & push image (push) Successful in 14s
2026-08-23 00:32:07 -04:00
bvandeusenandClaude Fable 5 85111442a6 feat(ledger): CSS derive families are names, never bodies — name floor 2 for css, dup: grouping sym-only; derive line says "repeated name" and dismisses scoped-css; plugin 0.1.41 (note #2917)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m0s
CI & Build / Build & push image (push) Successful in 25s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 00:21:17 -04:00
bvandeusenandClaude Fable 5 a2b377b74d chore(plugin): 0.1.40 — the extractor rule change in scribe_defs.sh ships to the executing cache (#2904)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 16s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 15:12:54 -04:00
bvandeusenandClaude Fable 5 590203a293 refactor(tests+frontend): one http_sink helper for the hook tests; apiErrorMessage replaces ten hand-rolled error-body parses; type X, import specifiers are not definitions (#2904, milestone 299 step 6)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Failing after 9s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m11s
CI & Build / Build & push image (push) Successful in 38s
tests/helpers.http_sink replaces three module-local _Sink handlers (the
write-path tests and the after-write test). ProjectView + SettingsView
parsed `(e as {body?:{error?}}).body?.error || fallback` by hand ten times
beside the apiErrorMessage canon (#2853) - all ten now call it. The
extractor (server + the hook awk mirror) no longer reads `import { type Foo }`
as a definition of Foo - that was the last "identical body" sym family.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 15:07:23 -04:00
bvandeusenandClaude Fable 5 449f437048 refactor(frontend): the near-duplicate report rules shared via dup-report.css — KnowledgeView and SnippetListView carried identical scoped copies (#2903, milestone 299 step 5, part 2)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m9s
CI & Build / Build & push image (push) Successful in 41s
KnowledgeView's own comment asked for this promotion once a second view grew the
panel. The sheet carries .dup-panel / .dup-empty,.dup-head / .dup-group /
.dup-members / .dup-member(+:hover) / .dup-score; each view keeps only its
extras (.dup-claimed, .dup-action).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 15:03:12 -04:00
bvandeusenandClaude Fable 5 48f0630dab test(coverage): the two-declaration floor check compares two multi-line rules — formatting is part of the fingerprint, as before (#2903)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 24s
CI & Build / integration (push) Successful in 29s
CI & Build / Python tests (push) Successful in 1m10s
CI & Build / Build & push image (push) Successful in 39s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 14:59:38 -04:00
bvandeusenandClaude Fable 5 a72605de8f refactor(frontend): the last pay-down, part 1 — three dead views deleted, editor rules shared, .page-container + .fs-input canon, rules-shared.css; a one-declaration CSS body is not a shape (#2903, milestone 299 step 5)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 34s
CI & Build / Python tests (push) Failing after 54s
CI & Build / Build & push image (push) Skipped
The derive queue said the biggest duplicate families were whole views:
TaskViewerView, UserManagementView and LogsView were imported nowhere — left
behind when tasks moved to the editor and users/logs became SettingsView
tabs. Deleted (rule 22). Note/TaskEditorView carried six identical scoped
rules -> editor-shared.css (the .tag-suggest-row gap the scoped copies
actually rendered wins). Three views wrapped the page under three names ->
.page-container in components.css. Three scoped input recipes -> the design
system fs-input recipe (snippet #2336), verbatim, in components.css; width/
box-sizing stay with the caller. The three rules panes share .pane and the
pane heading via rules-shared.css (the auth-shared pattern, #2852).

Ledger: a single-declaration CSS rule keeps its selector in its fingerprint,
so `color: var(--fs-text-tertiary)` under five different names is no longer
a five-file "identical body" family — the first pay-down found that most of
the 148 dup families were exactly this, and nobody would consolidate them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 14:55:28 -04:00
bvandeusen 4fa8158329 Merge pull request 'Self-surfacing DRY — duplicate families named at the write and on arrival, tool-agnostic (milestone 299 steps 1–4)' (#125) from dev into main
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 14s
2026-08-22 13:41:24 -04:00
bvandeusenandClaude Fable 5 10687120a5 docs(self-surfacing): derive groups are drift not audit material — shape-accounting + reusing-code skills, static floor, plugin README (after-write hook), api-reference rows (#2902, milestone 299 step 4)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 34s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 13:38:06 -04:00
bvandeusenandClaude Fable 5 b88225eeb3 fix(hooks): after-write dedups its channel files; prior-art tests follow the extractor and skip list into scribe_defs.sh (#2901)
Run 4240: two pre-write hook tests pinned the skip case and scribe_defs()
inside scribe_prior_art.sh, which moved to the shared library; the after-write
test saw the same derive key appended once per changed file. Keep each token
once (sort -u after the appends) and point the pins at the library.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 13:38:06 -04:00
bvandeusenandClaude Fable 5 5925335ca0 feat(plugin): after-write hook — PostToolUse on Bash diffs the working tree and runs the prior-art + ledger arms on what was just written; shared scribe_defs.sh; plugin 0.1.39 (#2901, milestone 299 step 3)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 40s
CI & Build / Build & push image (push) Skipped
Edits made through sed/heredocs/scripts never reached the PreToolUse
Write|Edit hook, so a whole class of writes got no prior-art hint, no
ledger feed and no duplicate-family warning. scribe_after_write.sh asks git
what changed since it last looked (per-session path+blob snapshot; first
call = files touched in the last minute), extracts the definitions in the
added lines and calls /api/plugin/prior-art with the same three dedup
channels the pre hook keeps. Never blocks; silent on any failure. The
extractor, the prose/data skip list and the local by-name arm move to
scribe_defs.sh, sourced by both hooks. Version bump covers the step-2 hook
change too (run 4239 failed only on the bump check).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 13:35:45 -04:00
bvandeusenandClaude Fable 5 2324c15418 feat(write-path): the derive arm — the hint names a duplicate family (no canon) or a canon elsewhere for the shapes being written; exclude_derive channel (#2900, milestone 299 step 2)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Failing after 9s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 23s
shape_ledger.write_time_derive asks the ledger what it knows about each
named (kind, symbol): a derive-grouped family (identical body / same name
in N other files) -> "derive it now, do not add a copy"; a canonical row
at another path -> "canon #N at <path>, reuse". Judged rows at the path and
the canon own file stay silent. Rendered by _derive_line beside the
divergence line; keyed (group id / canon:<id>) on a third per-session dedup
channel in the hook (.derive.ids -> exclude_derive=).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 13:30:33 -04:00
bvandeusenandClaude Fable 5 bb242ca566 feat(coverage): the line names standing work with 0 unclassified + derive_new, copies that joined a family since the previous refresh (#2899, milestone 299 step 1)
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 24s
Since the scoped bucket (#2869) the ledger reads 100% accounted while 439
derive rows stand; the standing block was gated on unclassified > 0 and so
went silent. Build it whatever the todo count ("; standing: ..."), and add
derive_new — derive-grouped rows first seen after the previous refresh
stamp — so entering a project names the drift ("+2 new copies since last
refresh: .error-msg in InceptionCard.vue") instead of waiting for an audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 13:27:29 -04:00
bvandeusen c0caf7d23a Merge pull request 'Project inception — decide what a new project inherits (milestone 297)' (#124) from dev into main
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 15s
2026-08-21 22:16:32 -04:00
bvandeusenandClaude Fable 5 dc2f32cc6f docs(inception): using-scribe skill teaches the inception questions; plugin 0.1.38; api-reference + README (#2884, milestone 297 step 6)
CI & Build / integration (push) Successful in 27s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 36s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 22:11:08 -04:00
bvandeusenandClaude Fable 5 10c63f49d8 fix(mcp): _INSTRUCTIONS back under the 2k fold — the inception line is one clause, detail lives in the tool docstrings (#2882)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Canceled after 33s
CI & Build / Build & push image (push) Canceled after 0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 22:10:28 -04:00
bvandeusenandClaude Fable 5 00c7badc3f feat(inception): UI — New-project modal step 2, InceptionCard on the project page, Rules tab shows excluded always-on rulebooks; REST exclusion routes (#2883, milestone 297 step 5)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Failing after 42s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
- components/InceptionCard.vue: the one form, two homes — mode="create" in
  the New-project modal's second step (emits the choices; the create carries
  `inception`), mode="decide" on ProjectView for the owner of an undecided
  project (loads that project's defaults, records the decision). Always-on
  rulebooks listed checked (uncheck = exclude), others unchecked (check =
  subscribe), design system select, seed-Systems toggle (disabled once the
  project has Systems). Tokens only; modal canon (#2855); .btn-* canon.
- ProjectView: the card while undecided, one "Inheritance decided <date> via
  … · …" line after; onDecided refreshes the project.
- ProjectRulesTab: "Excluded always-on rulebooks" section with include-back.
- api/inception.ts (types, fetchInceptionDefaults, decideInception);
  api/rulebooks.ts: ApplicableRules.excluded_always_on, exclude/include
  wrappers; REST POST/DELETE /api/projects/<id>/exclusions/rulebooks/<rb>.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 22:09:18 -04:00
bvandeusenandClaude Fable 5 c7a58bb610 feat(inception): the doors — create_project/decide_project_inception take the decision, enter_project asks until decided, REST inception endpoints, _INSTRUCTIONS (#2882, milestone 297 step 4)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Failing after 45s
CI & Build / Build & push image (push) Skipped
- MCP create_project(..., exclude_always_on_rulebooks, subscribe_rulebooks,
  design_system_id (0 unstated / -1 none / n), seed_systems): any inception
  arg → inception.decide(via="mcp") after the create; none → undecided with
  an inception_hint. New decide_project_inception(project_id, …) records or
  re-records; nothing given = an inherit-all decision, stated.
- enter_project carries `inception` ONLY for the caller's own, undecided
  project: inception_ask() = the project's current defaults + what to ask the
  operator once + the exact call (the #2683 ask shape). Absent otherwise.
- REST: POST /api/projects accepts `inception` (validated before the create);
  POST /api/projects/<id>/inception decides/re-decides; GET …/inception/defaults
  is the card's payload; GET project already carries inception via to_dict.
- _INSTRUCTIONS: ORIENT names the ask; START a project names the questions —
  never create a project bare by default (product behaviour, P#119).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 22:06:19 -04:00
bvandeusenandClaude Fable 5 227aef3dbf test: mocked-session rule tests stub the always-on exclusions lookup; tool count 24 (#2880)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 29s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 22:04:04 -04:00
bvandeusenandClaude Fable 5 34734bf84a feat(inception): services/inception.decide() + current_defaults(); the standard Systems vocabulary moves to the service and seeds at inception (#2881, milestone 297 step 3)
CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / integration (push) Successful in 36s
CI & Build / Python tests (push) Failing after 53s
CI & Build / Build & push image (push) Skipped
- inception.decide(user, project, choices=, via=): owner-only; validates the
  choices (pure) and every target (owned rulebook / always-on for an
  exclusion / readable design system) BEFORE any effect; then, each
  idempotent: exclude always-on rulebooks, subscribe rulebooks, point the
  design system (None = explicitly none), seed the standard Systems if asked
  and the project has none; writes projects.inception LAST. Re-deciding is
  additive for exclusions/subscriptions, replaces the design system, never
  re-seeds.
- inception.current_defaults(): what binds if nobody decides — the ask's
  payload (always-on / other rulebooks, standing exclusions + subscriptions,
  design system + the choices, Systems count).
- services/systems.STANDARD_SYSTEMS (name + generic charter) + seed_standard_systems();
  mcp/tools/systems names the same list in the bootstrap ask — one vocabulary.
- Integration tests: effects land and the record says why; bad targets apply
  nothing; outsiders cannot decide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 22:03:02 -04:00
bvandeusenandClaude Fable 5 ff5f6438c4 feat(inception): always-on exclusions reach every rule surface — list_always_on_rules(project_id), get_applicable_rules, rules_payload.excluded_always_on, session context, exclude/include tools (#2880, milestone 297 step 2)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Failing after 1m12s
CI & Build / Build & push image (push) Skipped
A project that opted out of an always-on rulebook at inception must not see
it anywhere: list_always_on_rules(project_id=) and the subscription-derived
set skip it (an exclusion is total), get_applicable_rules / rules_payload
carry `excluded_always_on` as the seventh key so the departure is visible
wherever the rules are, and the SessionStart block built for a bound project
names it ("Excluded for this project by its inception decision …"). MCP:
list_always_on_rules takes project_id; exclude_always_on_rulebook /
include_always_on_rulebook mirror suppress/unsuppress (owner-only; the
rulebook must be always_on — subscribed rulebooks are left by unsubscribing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 22:00:50 -04:00
bvandeusenandClaude Fable 5 e9b8f525c8 feat(inception): projects.inception record + project_rulebook_exclusions — migration 0085 with legacy backfill; backup v10 (#2879, milestone 297 step 1)
CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 42s
CI & Build / integration (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m36s
CI & Build / Build & push image (push) Successful in 36s
A project's inheritance becomes a decision, not a default (milestone 297).
- projects.inception (JSONB, NULL = undecided): {decided_at, decided_by, via
  mcp|ui|legacy, choices {exclude_always_on_rulebooks, subscribe_rulebooks,
  design_system_id, seed_systems}}; on to_dict.
- project_rulebook_exclusions: a project's opt-out of a whole always-on
  rulebook — the sibling of the rule/topic suppressions, CASCADE both ways.
- services/inception.py (first cut): the vocabulary, validate_inception
  (pure, all-or-nothing), normalize_choices, is_decided. Effects come in
  step 3.
- Migration 0085 backfills every existing project via="legacy" with its
  current standing (no exclusions, its subscriptions, its design_system_id,
  no seed) so the ask fires only for projects created after this ships.
- Backup v10: rulebook_exclusions section; project rows carry inception and
  design_system_id, restored in a post-pass once rulebooks/design systems are
  mapped (design_system_id was not restored before — fixed in passing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 21:57:02 -04:00
91 changed files with 4718 additions and 3127 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ A self-hosted work system-of-record for software projects, built to be driven by
## Features
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system, and semantic search with proactive knowledge-injection into Claude's context. A knowledge graph, per-user/group sharing, and a built-in MCP server (`/mcp`) plus a bundled Claude Code plugin so Claude can record and recall your work directly.
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system (with an inception step that decides what each project inherits), and semantic search with proactive knowledge-injection into Claude's context. A knowledge graph, per-user/group sharing, and a built-in MCP server (`/mcp`) plus a bundled Claude Code plugin so Claude can record and recall your work directly.
## Quick Start
@@ -0,0 +1,74 @@
"""Project inception: the decision record + always-on rulebook exclusions (milestone 297)
Revision ID: 0085
Revises: 0084
Create Date: 2026-08-22
`projects.inception` is the WHY a project inherits what it does — NULL until
someone decides, at which point enter_project stops asking. The new
association `project_rulebook_exclusions` is the opt-out of a whole always-on
rulebook for one project (the sibling of the rule/topic suppressions).
Backfill: every project that exists when this runs is stamped
via="legacy" with its CURRENT standing (no exclusions, its subscriptions,
its design_system_id, no seed) — so the ask fires only for projects created
after the step shipped, and nothing a running install relies on changes.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0085"
down_revision = "0084"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"projects",
sa.Column("inception", postgresql.JSONB(), nullable=True),
)
op.create_table(
"project_rulebook_exclusions",
sa.Column(
"project_id", sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
primary_key=True, nullable=False,
),
sa.Column(
"rulebook_id", sa.BigInteger(),
sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
primary_key=True, nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
)
# Legacy stamp: what each existing project inherits today, recorded as a
# decision so the inception ask does not fire on a project that has been
# running for months.
op.execute(sa.text("""
UPDATE projects p SET inception = jsonb_build_object(
'via', 'legacy',
'decided_at', to_jsonb(now()),
'decided_by', NULL,
'choices', jsonb_build_object(
'exclude_always_on_rulebooks', '[]'::jsonb,
'subscribe_rulebooks', COALESCE(
(SELECT jsonb_agg(s.rulebook_id ORDER BY s.rulebook_id)
FROM project_rulebook_subscriptions s
WHERE s.project_id = p.id),
'[]'::jsonb),
'design_system_id', to_jsonb(p.design_system_id),
'seed_systems', false
)
)
WHERE p.inception IS NULL
"""))
def downgrade() -> None:
op.drop_table("project_rulebook_exclusions")
op.drop_column("projects", "inception")
@@ -0,0 +1,35 @@
"""code_shape_consumers — the CSS consumer map (milestone 302, note 2917)
Revision ID: 0086
Revises: 0085
Create Date: 2026-08-23
CSS is watched by name, by recipe, by token and by WHAT USES IT. This table
holds the fourth: CSS shape → the file whose markup names its class, with how
many times. Mechanical and recomputed by every coverage sync from the repo
archive; the analogue of code_shape_uses for styling. Cascades with the shape.
"""
import sqlalchemy as sa
from alembic import op
revision = "0086"
down_revision = "0085"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"code_shape_consumers",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("shape_id", sa.Integer(), sa.ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False),
sa.Column("path", sa.Text(), nullable=False),
sa.Column("count", sa.Integer(), nullable=False, server_default="1"),
sa.Column("basis", sa.Text(), nullable=False, server_default="template"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.UniqueConstraint("shape_id", "path", name="uq_code_shape_consumers_shape_path"),
)
def downgrade() -> None:
op.drop_table("code_shape_consumers")
+5 -3
View File
@@ -43,8 +43,10 @@ client straight to the URL with a Bearer token.
Authenticate with an API key generated from **Settings → API Keys** (see above),
sent as `Authorization: Bearer fmcp_<key>`. A `read`-scoped key may call only the
read tools (`get_*`, `list_*`, `search`, `enter_project`); any write/delete tool
is rejected with `403`. A `write`-scoped key may call everything.
read tools (`get_*`, `list_*`, `search`, `enter_project`, `retrieval_telemetry`);
any write/delete tool is rejected with `403`. The allow-list is explicit rather
than derived from the name — see `_READ_ONLY_TOOLS`, which is why the two reads
without a read-shaped name are spelled out here. A `write`-scoped key may call everything.
### Claude Code (Project-scoped)
@@ -85,7 +87,7 @@ table here. The tools are grouped by family:
| Notes | `create_note`, `get_note`, `update_note`, `delete_note`, `list_notes` | Free-form knowledge |
| Tasks | `create_task`, `update_task`, `add_task_log`, `start_planning` | Actionable work + plans |
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
| Search / Recall | `search`, `get_recent`, `list_tags` | Semantic + structured recall |
| Search / Recall | `search`, `get_recent`, `list_tags`, `retrieval_telemetry` | Semantic + structured recall, and the readout its thresholds are tuned from |
| Systems | `create_system`, `list_systems`, `list_system_records` | Reusable per-project subsystems/areas |
| Rulebooks | `list_always_on_rules`, `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules |
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
+6 -1
View File
@@ -76,7 +76,9 @@ endpoint at `/mcp`, not these REST routes.
| Method | Path | Description |
|--------|------|-------------|
| GET / POST | `/api/projects` | List (owned + shared) / create |
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`) / update / delete |
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`, `inception`) / update / delete |
| POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) |
| GET | `/api/projects/:id/inception/defaults` | What binds if nobody decides — the inception card's payload |
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
| GET / PATCH / DELETE | `/api/projects/:id/milestones/:mid` | Read / update / delete |
@@ -118,6 +120,7 @@ endpoint at `/mcp`, not these REST routes.
| POST | `/api/projects/:id/rules` | Create a project-scoped rule |
| POST / DELETE | `/api/projects/:id/suppressions/rules/:rid` | Suppress / unsuppress a rule |
| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic |
| POST / DELETE | `/api/projects/:id/exclusions/rulebooks/:rid` | Exclude / include an always-on rulebook for this project (inception) |
## Sharing
@@ -169,6 +172,8 @@ endpoint at `/mcp`, not these REST routes.
| GET | `/api/plugin/context` | SessionStart context payload (rules + active-project) |
| GET | `/api/plugin/retrieve` | Title-first knowledge-injection candidates |
| GET | `/api/plugin/processes` | Stored Processes for skill-stub sync |
| GET | `/api/plugin/prior-art` | Write-path hint for the plugin hooks (params: `path`, `code`, `repo`, `shapes`, `exclude_ids`, `exclude_sync_ids`, `exclude_derive`); returns `context`, `note_ids`, `sync_note_ids`, `stamped`, `divergence`, `derive`, `derive_keys` |
| GET / POST | `/api/projects/<id>/coverage`, `…/coverage/refresh` | Shape-ledger accounting (`pattern_coverage` line, counts, `derive_groups` — css groups carry `consumers`, `derive_new`, `unused_css`, `divergence`, `recheck`) |
| GET / PUT | `/api/plugin/marketplace-url` | Read / set the plugin marketplace URL |
## Dashboard, Export, Trash, Users
+42
View File
@@ -0,0 +1,42 @@
/** Project inception (milestone 297): what a project was decided to inherit. */
import { apiGet, apiPost } from "@/api/client";
export interface InceptionChoices {
exclude_always_on_rulebooks: number[];
subscribe_rulebooks: number[];
design_system_id: number | null;
seed_systems: boolean;
}
export interface InceptionRecord {
decided_at: string;
decided_by: number | null;
via: "mcp" | "ui" | "legacy";
choices: InceptionChoices;
}
export interface InceptionDefaults {
always_on_rulebooks: { id: number; title: string }[];
other_rulebooks: { id: number; title: string }[];
excluded_always_on: { id: number; title: string }[];
subscribed_rulebooks: { id: number; title: string }[];
design_system_id: number | null;
design_systems: { id: number; title: string }[];
systems: number;
}
export interface InceptionDecision {
project_id: number;
inception: InceptionRecord;
effects: { excluded: number[]; subscribed: number[]; design_system_id: number | null; systems_seeded: string[] };
}
export const emptyChoices = (): InceptionChoices => ({
exclude_always_on_rulebooks: [], subscribe_rulebooks: [], design_system_id: null, seed_systems: false,
});
export const fetchInceptionDefaults = (projectId: number) =>
apiGet<InceptionDefaults>(`/api/projects/${projectId}/inception/defaults`);
export const decideInception = (projectId: number, choices: InceptionChoices) =>
apiPost<InceptionDecision>(`/api/projects/${projectId}/inception`, { choices });
+13
View File
@@ -71,6 +71,8 @@ export interface ApplicableRules {
}[];
truncated: boolean;
subscribed_rulebooks: { id: number; title: string }[];
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
excluded_always_on: { id: number; title: string }[];
}
// ── Rulebooks ───────────────────────────────────────────────────────
@@ -181,3 +183,14 @@ export async function suppressTopicForProject(projectId: number, topicId: number
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
}
// ── Always-on exclusions (milestone 297) ────────────────────────────────────
export async function excludeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`, {});
}
export async function includeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`);
}
+54
View File
@@ -297,3 +297,57 @@
background: var(--fs-action-destructive-hover);
border-color: var(--fs-action-destructive-hover);
}
/* ── Page container ─────────────────────────────────────────────────────────
The one wrapper a top-level view sits in: page width from the layout
tokens, centred, clipped horizontally so a wide child (a kanban, a table)
scrolls inside itself instead of the page. ProjectListView, ProjectView and
SnippetListView each carried this rule under their own name until #2903
(milestone 299). */
.page-container {
max-width: var(--fs-layout-page-max);
margin: 2rem auto;
padding: 0 var(--fs-layout-page-pad);
overflow-x: clip;
}
/* ── Form input (fs-surfaces, snippet #2336) ────────────────────────────────
Inputs sit DARKER than the page they're on — an inset well rather than a
raised panel; that inversion is what makes a field read as writable. The
design system's recipe, verbatim; width/box-sizing stay the caller's
(an inline select and a full-width textarea differ there). Three scoped
copies of an older input recipe were folded into this in #2903. */
.fs-input {
background: var(--fs-surface-page);
border: var(--fs-border);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-2) var(--fs-space-3); /* 8px 12px */
color: var(--fs-text-primary);
font-family: var(--fs-font-body);
font-size: var(--fs-size-body);
transition: box-shadow var(--fs-dur-fast) var(--fs-ease);
}
.fs-input::placeholder { color: var(--fs-text-tertiary); }
.fs-input:focus { outline: none; box-shadow: var(--fs-focus-ring); }
.fs-input:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
/* Page scaffold + feedback text recipes (milestone 302, note 2917): name
families the consumer map showed to be one recipe living in many views.
A view keeps only its deviation as a scoped remainder/override. */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.page-header h1 { margin: 0; }
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
.state-msg { color: var(--fs-text-tertiary); font-size: 0.9rem; }
.empty-msg { color: var(--fs-text-tertiary); font-size: 0.875rem; }
.empty-title { font-size: 1rem; font-weight: 500; color: var(--fs-text-secondary); margin: 0 0 0.35rem; }
.empty-sub { font-size: 0.85rem; color: var(--fs-text-tertiary); margin: 0 0 1rem; }
.required { color: var(--fs-error); }
.field-hint { margin: 0.3rem 0 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
+50
View File
@@ -0,0 +1,50 @@
/* The near-duplicate report, shared by KnowledgeView (notes/tasks) and
SnippetListView (snippets) so the two reports read as one feature. Load
with <style src="@/assets/dup-report.css" /> beside the view's scoped
block; the view keeps only its own extras (.dup-claimed, .dup-action).
Promoted from two identical scoped copies in #2903 (milestone 299). */
.dup-panel {
margin-bottom: 1.25rem;
padding: 0.85rem 1rem;
border: 1px solid var(--fs-border-color);
border-radius: 8px;
background: var(--fs-surface-hover);
}
.dup-empty,
.dup-head {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--fs-text-tertiary);
}
.dup-empty { margin-bottom: 0; }
.dup-group {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.5rem 0;
border-top: 1px solid var(--fs-border-color);
}
.dup-members {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
flex: 1 1 20rem;
min-width: 0;
}
.dup-member {
font-size: 0.8rem;
padding: 0.1rem 0.45rem;
border-radius: 4px;
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
color: var(--fs-text-primary);
text-decoration: none;
overflow-wrap: anywhere;
}
.dup-member:hover { background: var(--fs-surface-hover); }
.dup-score {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
+34 -152
View File
@@ -15,18 +15,6 @@
padding: 1rem 1.5rem 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
}
.editor-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.editor-main {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 0.75rem 1.5rem 1.5rem;
}
/* ── Toolbar & inputs ── */
.toolbar {
@@ -78,7 +66,7 @@
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
gap: 0.3rem;
}
.tag-pill {
display: inline-flex;
@@ -106,95 +94,6 @@
.tag-check {
font-size: 0.7rem;
}
/* ── Assist panel ── */
.assist-panel {
width: 320px;
flex-shrink: 0;
border-left: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
display: flex;
flex-direction: column;
overflow: hidden;
}
.assist-panel-header {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 0.9rem;
border-bottom: 1px solid var(--fs-border-color);
}
.assist-panel-title {
flex: 1;
font-size: 0.8rem;
font-weight: 500;
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.assist-panel-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0.75rem 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
/* Section list */
.assist-sections-label {
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fs-text-tertiary);
margin-bottom: 0.2rem;
}
.assist-sections {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
max-height: 200px;
overflow-y: auto;
flex-shrink: 0;
}
.assist-section-item {
padding: 0.35rem 0.7rem;
cursor: pointer;
font-size: 0.82rem;
border-left: 3px solid transparent;
color: var(--fs-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.assist-section-item:hover {
background: var(--fs-surface-raised);
}
.assist-section-item.selected {
border-left-color: var(--fs-accent);
background: var(--fs-surface-raised);
font-weight: 500;
}
.assist-empty,
.assist-hint {
padding: 0.6rem 0.7rem;
font-size: 0.82rem;
color: var(--fs-text-tertiary);
}
.assist-target-preview {
font-size: 0.8rem;
color: var(--fs-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assist-target-preview em {
font-style: normal;
color: var(--fs-text-primary);
}
.assist-instruction {
width: 100%;
padding: 0.5rem 0.65rem;
@@ -213,33 +112,6 @@
gap: 0.5rem;
}
/* Streaming */
.assist-streaming-label {
font-size: 0.8rem;
color: var(--fs-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assist-preview-box {
padding: 0.65rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
font-size: 0.9rem;
max-height: 300px;
overflow-y: auto;
}
.typing-indicator {
color: var(--fs-text-tertiary);
font-size: 0.75rem;
letter-spacing: 0.15em;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
/* Active hint shown in the panel while output is inline */
.assist-active-hint {
padding: 0.5rem 0.75rem;
@@ -257,16 +129,6 @@
font-size: 0.85rem;
color: var(--fs-error);
}
/* Review / diff */
.assist-review-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8rem;
font-weight: 500;
color: var(--fs-text-secondary);
}
.diff-view {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
@@ -398,22 +260,9 @@
/* ── Mobile ── */
@media (max-width: 768px) {
.editor-body {
flex-direction: column;
}
.assist-panel {
width: auto;
flex: 0 0 45%;
border-left: none;
border-top: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg) var(--fs-radius-lg) 0 0;
}
.editor-header {
padding: 0.75rem 1rem 0.5rem;
}
.editor-main {
padding: 0.5rem 1rem 1rem;
}
}
/* ---------------------------------------------------------------------------
@@ -508,3 +357,36 @@
opacity: var(--fs-disabled-opacity);
cursor: not-allowed;
}
/* Shared by NoteEditorView and TaskEditorView — both carried identical scoped
copies of these until #2903 (milestone 299); one source here. */
.body-tabs-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
}
.body-editor-wrap {
min-height: 200px;
}
.stream-preview {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.75rem;
background: var(--fs-surface-raised);
min-height: 200px;
}
.main-diff {
flex: 1;
min-height: 0;
}
.assist-section-title {
font-size: 0.78rem;
font-weight: 500;
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
+15
View File
@@ -0,0 +1,15 @@
/* Shared by the three rules panes (RulebookListPane, RuleListPane,
RulebookDetailPane): the pane surface and its heading. Load with
<style src="@/assets/rules-shared.css" /> beside the component's own
scoped block; never restate these there (#2903, milestone 299). */
.pane {
background: var(--fs-surface-hover);
padding: 1rem;
overflow-y: auto;
}
.pane header h2 {
font-family: Fraunces, serif;
font-style: italic;
margin: 0 0 0.5rem 0;
}
.form-buttons { display: flex; gap: 0.5rem; }
+1 -8
View File
@@ -272,17 +272,10 @@ button:not(:disabled):active,
display: none !important;
}
button,
[role="button"],
.btn-new-conv,
.btn-send {
[role="button"] {
min-height: 44px;
}
}
@media (min-width: 769px) {
.hide-desktop {
display: none !important;
}
}
/* Neutral hairline scrollbars — chrome is structural, not branded */
::-webkit-scrollbar {
+1 -40
View File
@@ -212,43 +212,6 @@ router.afterEach(() => {
box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent);
}
/* Status indicator */
.status-indicator {
display: flex;
align-items: center;
gap: 0.3rem;
cursor: default;
padding: 0 0.25rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-text {
font-size: 0.75rem;
font-weight: 500;
color: var(--fs-text-tertiary);
}
/* Status dots are indicator lights, not semantic-palette buttons —
they want to read as vital (Moss/Warning/Error are too muted for
a "ready" indicator). Hardcoded bright values; the rest of the
system still uses the semantic tokens. */
.status-green .status-dot { background: #4ade80; animation: status-pulse 2.5s ease-in-out infinite; }
.status-yellow .status-dot { background: #facc15; animation: pulse-dot 2s infinite; }
.status-orange .status-dot { background: #f97316; }
.status-red .status-dot { background: #ef4444; }
.status-gray .status-dot { background: var(--fs-text-tertiary); animation: pulse-dot 2s infinite; }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
@keyframes status-pulse {
0%, 100% { box-shadow: 0 0 4px rgba(74, 222, 128, 0.4); }
50% { box-shadow: 0 0 10px rgba(74, 222, 128, 0.6); }
}
/* Icon buttons (?, theme, gear) */
.btn-icon {
background: none;
@@ -263,8 +226,7 @@ router.afterEach(() => {
align-items: center;
justify-content: center;
}
.btn-icon:hover,
.btn-icon.active {
.btn-icon:hover {
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-color: var(--fs-accent);
@@ -382,7 +344,6 @@ router.afterEach(() => {
.nav-center {
display: none;
}
.status-indicator,
.btn-icon,
.user-info {
display: none;
+188
View File
@@ -0,0 +1,188 @@
<script setup lang="ts">
/**
* The inception form (milestone 297): "what does this project inherit?"
*
* Two homes, one component. mode="create" rides the New-project modal's
* second step and only emits the choices (the project does not exist yet);
* mode="decide" sits on ProjectView for an undecided project, loads that
* project's current defaults, and records the decision itself.
*/
import { computed, onMounted, ref, watch } from "vue";
import { apiErrorMessage } from "@/api/client";
import { fetchDesignSystems } from "@/api/designSystems";
import {
decideInception, emptyChoices, fetchInceptionDefaults,
type InceptionChoices, type InceptionDecision, type InceptionDefaults,
} from "@/api/inception";
import { listRulebooks } from "@/api/rulebooks";
const props = withDefaults(defineProps<{
mode: "create" | "decide";
projectId?: number;
choices?: InceptionChoices;
}>(), { projectId: 0, choices: undefined });
const emit = defineEmits<{
"update:choices": [value: InceptionChoices];
decided: [decision: InceptionDecision];
}>();
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
const alwaysOn = ref<{ id: number; title: string }[]>([]);
const others = ref<{ id: number; title: string }[]>([]);
const designSystems = ref<{ id: number; title: string }[]>([]);
const systemsCount = ref(0);
const loading = ref(true);
const saving = ref(false);
const error = ref("");
function emitChoices() {
emit("update:choices", { ...local.value });
}
watch(local, emitChoices, { deep: true });
async function load() {
loading.value = true;
error.value = "";
try {
if (props.mode === "decide" && props.projectId) {
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
alwaysOn.value = d.always_on_rulebooks;
others.value = d.other_rulebooks;
designSystems.value = d.design_systems;
systemsCount.value = d.systems;
// Start from what stands today so "record" without changes is a true inherit-all.
local.value = {
exclude_always_on_rulebooks: d.excluded_always_on.map((r) => r.id),
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
design_system_id: d.design_system_id,
seed_systems: false,
};
} else {
const [rulebooks, ds] = await Promise.all([listRulebooks(), fetchDesignSystems()]);
alwaysOn.value = rulebooks.filter((r) => r.always_on).map((r) => ({ id: r.id, title: r.title }));
others.value = rulebooks.filter((r) => !r.always_on).map((r) => ({ id: r.id, title: r.title }));
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
}
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not load what this project could inherit");
} finally {
loading.value = false;
}
}
function inherits(id: number): boolean {
return !local.value.exclude_always_on_rulebooks.includes(id);
}
function toggleInherit(id: number) {
const list = local.value.exclude_always_on_rulebooks;
local.value.exclude_always_on_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
}
function subscribed(id: number): boolean {
return local.value.subscribe_rulebooks.includes(id);
}
function toggleSubscribe(id: number) {
const list = local.value.subscribe_rulebooks;
local.value.subscribe_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
}
const nothingToDecide = computed(
() => !alwaysOn.value.length && !others.value.length && !designSystems.value.length,
);
async function record() {
if (!props.projectId) return;
saving.value = true;
error.value = "";
try {
const decision = await decideInception(props.projectId, local.value);
emit("decided", decision);
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not record the decision");
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="inception" aria-labelledby="inception-title">
<h3 id="inception-title" class="inception-title">What does this project inherit?</h3>
<p class="inception-lede">
A project's inheritance is a decision, not a default. Until it is recorded,
every always-on rulebook binds, nothing is subscribed, and there is no design
system or Systems.
</p>
<p v-if="loading" class="inception-muted">Loading…</p>
<p v-else-if="error" class="error-msg">{{ error }}</p>
<template v-else>
<div v-if="alwaysOn.length" class="inception-group">
<h4>Always-on rulebooks</h4>
<p class="inception-muted">Checked = inherits. Uncheck to exclude a rulebook for this project only.</p>
<label v-for="rb in alwaysOn" :key="rb.id" class="inception-choice">
<input type="checkbox" :checked="inherits(rb.id)" @change="toggleInherit(rb.id)" />
<span>{{ rb.title }}</span>
</label>
</div>
<div v-if="others.length" class="inception-group">
<h4>Subscribe to rulebooks</h4>
<label v-for="rb in others" :key="rb.id" class="inception-choice">
<input type="checkbox" :checked="subscribed(rb.id)" @change="toggleSubscribe(rb.id)" />
<span>{{ rb.title }}</span>
</label>
</div>
<div class="inception-group">
<h4>Design system</h4>
<select v-model="local.design_system_id" class="inception-select" aria-label="Design system">
<option :value="null">None</option>
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
</select>
</div>
<div class="inception-group">
<label class="inception-choice">
<input type="checkbox" v-model="local.seed_systems" :disabled="systemsCount > 0" />
<span>
Seed the standard starter Systems (CI &amp; Release, Auth &amp; Access, Data Model &amp; Storage, …)
<em v-if="systemsCount > 0" class="inception-muted"> — this project already has {{ systemsCount }}</em>
</span>
</label>
</div>
<p v-if="nothingToDecide" class="inception-muted">
Nothing to inherit yet on this install — recording still settles the question.
</p>
<div v-if="mode === 'decide'" class="inception-actions">
<button class="btn-primary" :disabled="saving" @click="record">
{{ saving ? "Recording" : "Record decision" }}
</button>
</div>
</template>
</section>
</template>
<style scoped>
.inception {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
}
.inception-title { margin: 0 0 0.35rem; font-size: 1.05rem; }
.inception-lede { margin: 0 0 1rem; color: var(--fs-text-secondary); font-size: 0.9rem; }
.inception-muted { color: var(--fs-text-tertiary); font-size: 0.85rem; margin: 0 0 0.35rem; }
.inception-group { margin-bottom: 1rem; }
.inception-group h4 { margin: 0 0 0.35rem; font-size: 0.9rem; font-weight: 500; }
.inception-choice { display: flex; align-items: flex-start; gap: 0.5rem; font-size: 0.9rem; margin: 0.25rem 0; }
.inception-choice input { margin-top: 0.2rem; accent-color: var(--fs-accent); }
.inception-select {
padding: 0.45rem 0.7rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.9rem;
}
.inception-actions { display: flex; justify-content: flex-end; margin-top: 0.5rem; }
</style>
+3 -16
View File
@@ -51,7 +51,7 @@ function onChange(e: Event) {
<template>
<select
class="milestone-select"
class="fs-input milestone-select"
:value="modelValue ?? ''"
:disabled="!projectId || loading"
@change="onChange"
@@ -64,23 +64,10 @@ function onChange(e: Event) {
</template>
<style scoped>
/* The input itself is the .fs-input canon (components.css); only the
layout remainder lives here. */
.milestone-select {
padding: 0.4rem 0.6rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
width: 100%;
}
.milestone-select:focus {
outline: none;
border-color: var(--fs-accent);
}
.milestone-select:disabled {
opacity: 0.5;
cursor: default;
}
</style>
-2
View File
@@ -231,7 +231,6 @@ onMounted(async () => {
color: var(--fs-text-primary);
}
.share-tabs {
display: flex;
gap: 0.25rem;
@@ -307,7 +306,6 @@ onMounted(async () => {
.user-result-item:hover { background: var(--fs-surface-raised); }
.user-result-name { font-weight: 600; font-size: 0.88rem; }
.user-result-email { color: var(--fs-text-tertiary); font-size: 0.8rem; }
.perm-select {
padding: 0.45rem 0.5rem;
+10 -19
View File
@@ -176,7 +176,7 @@ async function confirmDelete() {
<form v-if="showCreate" class="system-form" @submit.prevent="submitCreate">
<input
v-model="newName"
class="system-input"
class="fs-input system-input"
placeholder="System name"
aria-label="System name"
autofocus
@@ -184,7 +184,7 @@ async function confirmDelete() {
/>
<textarea
v-model="newDescription"
class="system-textarea"
class="fs-input system-textarea"
rows="2"
placeholder="What is this subsystem responsible for? (optional)"
aria-label="System description"
@@ -227,7 +227,7 @@ async function confirmDelete() {
<form class="system-form system-form--inline" @submit.prevent="submitEdit(system)">
<input
v-model="editName"
class="system-input"
class="fs-input system-input"
placeholder="System name"
aria-label="System name"
autofocus
@@ -235,7 +235,7 @@ async function confirmDelete() {
/>
<textarea
v-model="editDescription"
class="system-textarea"
class="fs-input system-textarea"
rows="2"
placeholder="Description (optional)"
aria-label="System description"
@@ -372,18 +372,9 @@ async function confirmDelete() {
border-radius: var(--fs-radius-lg);
}
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
.system-input, .system-textarea {
padding: 0.4rem 0.6rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
width: 100%;
}
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--fs-accent); }
/* The input itself is the .fs-input canon (components.css); only the
layout remainder lives here. */
.system-input, .system-textarea { box-sizing: border-box; width: 100%; }
.system-textarea { resize: vertical; }
.system-form-actions { display: flex; gap: 0.4rem; }
@@ -493,10 +484,10 @@ async function confirmDelete() {
border: 1px dashed var(--fs-border-color);
border-radius: var(--fs-radius-lg);
}
.empty-title { margin: 0; font-weight: 500; color: var(--fs-text-primary); }
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--fs-text-tertiary); max-width: 32ch; }
/* remainders over the shared recipes (components.css, m302) */
.empty-title { margin: 0; color: var(--fs-text-primary); }
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; max-width: 32ch; }
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
/* ── Skeleton ─────────────────────────────────────────────────── */
@keyframes skel-shine { to { background-position: 200% center; } }
@@ -471,7 +471,6 @@ defineExpose({ reload: loadProjectNotes });
flex: 1;
}
.rail-search-input {
flex: 1;
background: transparent;
@@ -575,8 +574,6 @@ defineExpose({ reload: loadProjectNotes });
align-items: center;
}
.note-row:hover .btn-delete { opacity: 1; }
/* Editor UI */
.panel-header {
display: flex;
@@ -624,8 +621,6 @@ defineExpose({ reload: loadProjectNotes });
}
.tag-row > :first-child { flex: 1; min-width: 0; }
.btn-suggest-tags { flex-shrink: 0; align-self: center; }
.tag-suggestions {
display: flex;
flex-wrap: wrap;
@@ -653,7 +648,6 @@ defineExpose({ reload: loadProjectNotes });
color: var(--fs-accent);
}
.link-suggest-strip {
display: flex;
align-items: center;
@@ -2,10 +2,18 @@
import { ref, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import {
getProjectApplicableRules, subscribeProject, unsubscribeProject,
listRulebooks, getRule, createProjectRule, deleteRule,
suppressRuleForProject, unsuppressRuleForProject,
suppressTopicForProject, unsuppressTopicForProject,
getProjectApplicableRules,
subscribeProject,
unsubscribeProject,
listRulebooks,
getRule,
createProjectRule,
deleteRule,
suppressRuleForProject,
unsuppressRuleForProject,
suppressTopicForProject,
unsuppressTopicForProject,
includeAlwaysOnRulebook,
} from "@/api/rulebooks";
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
@@ -35,6 +43,11 @@ async function subscribe(rulebookId: number) {
await load();
}
async function includeBack(rulebookId: number) {
await includeAlwaysOnRulebook(props.projectId, rulebookId);
await load();
}
async function unsubscribe(rulebookId: number) {
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
await unsubscribeProject(props.projectId, rulebookId);
@@ -172,6 +185,17 @@ watch(() => props.projectId, load);
</div>
</section>
<section v-if="applicable.excluded_always_on?.length" class="excluded">
<h3>Excluded always-on rulebooks</h3>
<p class="excluded-note">Opted out at inception these do not bind this project.</p>
<div class="chips">
<span v-for="rb in applicable.excluded_always_on" :key="rb.id" class="chip chip-excluded">
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
<button class="chip-remove" @click="includeBack(rb.id)" aria-label="Include again" title="Include again"></button>
</span>
</div>
</section>
<section class="project-rules">
<div class="section-head">
<h3>Project rules</h3>
@@ -321,6 +345,9 @@ watch(() => props.projectId, load);
</template>
<style scoped>
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
.chip-excluded { opacity: 0.8; text-decoration: line-through; }
.chip-excluded .chip-remove { text-decoration: none; }
.rules-tab { padding: 1rem; }
h3 {
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
@@ -21,9 +21,8 @@ const emit = defineEmits<{
</section>
</template>
<style src="@/assets/rules-shared.css" />
<style scoped>
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
ul { list-style: none; padding: 0; margin: 1rem 0; }
li {
padding: 0.75rem;
@@ -121,10 +121,9 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
</section>
</template>
<style src="@/assets/rules-shared.css" />
<style scoped>
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
.always-on-toggle {
display: flex; align-items: center; gap: 0.4rem;
font-size: 0.85rem; opacity: 0.85; cursor: pointer;
@@ -146,7 +145,6 @@ li:hover { background: var(--fs-surface-hover); }
border: 1px solid var(--fs-border-color); border-radius: 6px;
padding: 0.5rem;
}
.form-buttons { display: flex; gap: 0.5rem; }
.subscriptions {
margin-top: 2rem;
border-top: 1px solid var(--fs-border-color);
@@ -47,9 +47,8 @@ async function submitNew() {
</aside>
</template>
<style src="@/assets/rules-shared.css" />
<style scoped>
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
li.active { background: var(--fs-accent-soft); }
@@ -71,6 +70,5 @@ li:hover { background: var(--fs-surface-hover); }
border: 1px solid var(--fs-border-color); border-radius: 6px;
padding: 0.5rem;
}
.form-buttons { display: flex; gap: 0.5rem; }
button { cursor: pointer; }
</style>
+19 -26
View File
@@ -557,14 +557,14 @@ function isSelfContainedColour(value: string): boolean {
<div class="field">
<label class="field-label" for="first-title">Title</label>
<input
id="first-title" v-model="newTitle" class="input" type="text"
id="first-title" v-model="newTitle" class="fs-input input" type="text"
placeholder="Your house style" @keyup.enter="submitCreate"
/>
</div>
<div class="field">
<label class="field-label" for="first-desc">Description</label>
<input
id="first-desc" v-model="newDescription" class="input" type="text"
id="first-desc" v-model="newDescription" class="fs-input input" type="text"
placeholder="What it covers"
/>
</div>
@@ -612,20 +612,20 @@ function isSelfContainedColour(value: string): boolean {
<div class="field">
<label class="field-label" for="new-title">Title</label>
<input
id="new-title" v-model="newTitle" class="input" type="text"
id="new-title" v-model="newTitle" class="fs-input input" type="text"
placeholder="A house style, or one app in it" @keyup.enter="submitCreate"
/>
</div>
<div class="field">
<label class="field-label" for="new-desc">Description</label>
<input
id="new-desc" v-model="newDescription" class="input" type="text"
id="new-desc" v-model="newDescription" class="fs-input input" type="text"
placeholder="What it covers"
/>
</div>
<div class="field">
<label class="field-label" for="new-parent">Inherits from</label>
<select id="new-parent" v-model="newParentId" class="input">
<select id="new-parent" v-model="newParentId" class="fs-input input">
<option :value="null">Nothing — this is a family system</option>
<option v-for="s in systems" :key="s.id" :value="s.id">{{ s.title }}</option>
</select>
@@ -659,16 +659,16 @@ function isSelfContainedColour(value: string): boolean {
<div class="field">
<label class="field-label" for="edit-title">Title</label>
<input id="edit-title" v-model="editTitle" class="input" type="text" />
<input id="edit-title" v-model="editTitle" class="fs-input input" type="text" />
</div>
<div class="field">
<label class="field-label" for="edit-desc">Description</label>
<input id="edit-desc" v-model="editDescription" class="input" type="text" />
<input id="edit-desc" v-model="editDescription" class="fs-input input" type="text" />
</div>
<div class="field">
<label class="field-label" for="edit-guidance">Guidance</label>
<textarea
id="edit-guidance" v-model="editGuidance" class="input" rows="5"
id="edit-guidance" v-model="editGuidance" class="fs-input input" rows="5"
placeholder="Aesthetic, voice and tone, what's deliberately out of scope"
></textarea>
<p class="field-hint">
@@ -678,7 +678,7 @@ function isSelfContainedColour(value: string): boolean {
</div>
<div class="field">
<label class="field-label" for="edit-parent">Inherits from</label>
<select id="edit-parent" v-model="editParentId" class="input">
<select id="edit-parent" v-model="editParentId" class="fs-input input">
<option :value="null">Nothing — this is a family system</option>
<option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.title }}</option>
</select>
@@ -887,19 +887,19 @@ function isSelfContainedColour(value: string): boolean {
<div class="field">
<label class="field-label" for="token-name">Name</label>
<input
id="token-name" v-model="tokenName" class="input mono" type="text"
id="token-name" v-model="tokenName" class="fs-input input mono" type="text"
placeholder="--surface-page"
/>
</div>
<div class="field-row">
<div class="field">
<label class="field-label" for="token-group">Group</label>
<input id="token-group" v-model="tokenGroup" class="input" type="text" placeholder="surface" />
<input id="token-group" v-model="tokenGroup" class="fs-input input" type="text" placeholder="surface" />
</div>
<div class="field">
<label class="field-label" for="token-purpose">Purpose</label>
<input
id="token-purpose" v-model="tokenPurpose" class="input" type="text"
id="token-purpose" v-model="tokenPurpose" class="fs-input input" type="text"
placeholder="page background, deepest surface"
/>
</div>
@@ -908,7 +908,7 @@ function isSelfContainedColour(value: string): boolean {
<div class="field">
<label class="field-label" for="token-rationale">Why this value</label>
<input
id="token-rationale" v-model="tokenRationale" class="input" type="text"
id="token-rationale" v-model="tokenRationale" class="fs-input input" type="text"
placeholder="Matches the primary action colour, deliberately"
/>
<p class="field-hint">
@@ -920,7 +920,7 @@ function isSelfContainedColour(value: string): boolean {
<div class="field">
<label class="field-label" for="token-supersedes">Use instead of</label>
<input
id="token-supersedes" v-model="tokenSupersedes" class="input mono" type="text"
id="token-supersedes" v-model="tokenSupersedes" class="fs-input input mono" type="text"
placeholder="#fff, #ffffff"
/>
<p class="field-hint">
@@ -941,8 +941,8 @@ function isSelfContainedColour(value: string): boolean {
</template>
</p>
<div v-for="(row, i) in tokenModes" :key="i" class="mode-row">
<input v-model="row.mode" class="input mono mode-key" type="text" placeholder="base" />
<input v-model="row.value" class="input mono" type="text" placeholder="#14171a" />
<input v-model="row.mode" class="fs-input input mono mode-key" type="text" placeholder="base" />
<input v-model="row.value" class="fs-input input mono" type="text" placeholder="#14171a" />
<span
v-if="isSelfContainedColour(row.value)" class="swatch"
:style="{ background: row.value }" aria-hidden="true"
@@ -1287,20 +1287,13 @@ function isSelfContainedColour(value: string): boolean {
}
.field-hint {
margin: 0.3rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
line-height: 1.5;
line-height: 1.5; /* remainder over the shared recipe */
}
/* remainder over .fs-input (components.css, canon #2336; m302) */
.input {
width: 100%;
padding: 0.45rem 0.6rem;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
color: var(--fs-text-primary);
font: inherit;
box-sizing: border-box;
}
+1 -59
View File
@@ -574,6 +574,7 @@ onUnmounted(() => {
</div>
</template>
<style src="@/assets/dup-report.css" />
<style scoped>
/* ── Root layout ─────────────────────────────────────────── */
.knowledge-root {
@@ -606,14 +607,6 @@ onUnmounted(() => {
text-decoration: none;
font-size: 0.78rem;
}
.today-link {
color: var(--fs-accent);
text-decoration: none;
font-weight: 500;
opacity: 0.85;
transition: opacity 0.15s;
}
.today-link:hover { opacity: 1; }
/* ── Main layout ─────────────────────────────────────────── */
.knowledge-layout {
@@ -1041,57 +1034,6 @@ onUnmounted(() => {
height: 100%;
}
/* ── Near-duplicate report ──────────────────────────────────────────────────
Mirrors SnippetListView's panel so the two reports read as one feature.
Scoped styles can't be shared across SFCs; if a third view ever grows this
panel, promote the family to components.css and record it (#2464's rule:
two-or-more is when a recipe earns the shared sheet). */
.dup-panel {
margin-bottom: 1.25rem;
padding: 0.85rem 1rem;
border: 1px solid var(--fs-border-color);
border-radius: 8px;
background: var(--fs-surface-hover);
}
.dup-empty,
.dup-head {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--fs-text-tertiary);
}
.dup-empty { margin-bottom: 0; }
.dup-group {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.5rem 0;
border-top: 1px solid var(--fs-border-color);
}
.dup-members {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
flex: 1 1 20rem;
min-width: 0;
}
.dup-member {
font-size: 0.8rem;
padding: 0.1rem 0.45rem;
border-radius: 4px;
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
color: var(--fs-text-primary);
text-decoration: none;
overflow-wrap: anywhere;
}
.dup-member:hover { background: var(--fs-surface-hover); }
.dup-score {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* A set someone already ruled on — quiet, not celebratory: it means "skip". */
.dup-claimed {
font-size: 0.72rem;
-485
View File
@@ -1,485 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { apiGet } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import PaginationBar from "@/components/PaginationBar.vue";
import { fmtLogStamp } from "@/utils/dateFormat";
const toastStore = useToastStore();
interface LogEntry {
id: number;
category: string;
user_id: number | null;
username: string | null;
action: string | null;
endpoint: string | null;
method: string | null;
status_code: number | null;
duration_ms: number | null;
ip_address: string | null;
details: string | null;
created_at: string;
}
interface LogStats {
audit: number;
usage: number;
error: number;
total: number;
}
const logs = ref<LogEntry[]>([]);
const stats = ref<LogStats>({ audit: 0, usage: 0, error: 0, total: 0 });
const total = ref(0);
const loading = ref(true);
const expandedId = ref<number | null>(null);
// Filters
const category = ref("");
const search = ref("");
const dateFrom = ref("");
const dateTo = ref("");
const limit = 50;
const offset = ref(0);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
onMounted(async () => {
await Promise.all([fetchLogs(), fetchStats()]);
loading.value = false;
});
watch([category, dateFrom, dateTo], () => {
offset.value = 0;
fetchLogs();
});
watch(search, () => {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
offset.value = 0;
fetchLogs();
}, 300);
});
watch(offset, () => {
fetchLogs();
});
async function fetchLogs() {
try {
const params = new URLSearchParams();
if (category.value) params.set("category", category.value);
if (search.value) params.set("search", search.value);
if (dateFrom.value) params.set("date_from", dateFrom.value);
if (dateTo.value) params.set("date_to", dateTo.value);
params.set("limit", String(limit));
params.set("offset", String(offset.value));
const data = await apiGet<{ logs: LogEntry[]; total: number }>(
`/api/admin/logs?${params}`
);
logs.value = data.logs;
total.value = data.total;
} catch {
toastStore.show("Failed to load logs", "error");
}
}
async function fetchStats() {
try {
stats.value = await apiGet<LogStats>("/api/admin/logs/stats");
} catch {
// Ignore
}
}
function toggleExpand(id: number) {
expandedId.value = expandedId.value === id ? null : id;
}
function formatDetails(details: string | null): string {
if (!details) return "";
try {
return JSON.stringify(JSON.parse(details), null, 2);
} catch {
return details;
}
}
function displayLabel(entry: LogEntry): string {
if (entry.category === "audit" && entry.action) return entry.action;
if (entry.endpoint) return entry.endpoint;
return "—";
}
function clearFilters() {
category.value = "";
search.value = "";
dateFrom.value = "";
dateTo.value = "";
offset.value = 0;
}
</script>
<template>
<main class="logs-page">
<h1>Application Logs</h1>
<section class="settings-section stats-section">
<div class="stats-grid">
<div class="stat-card">
<span class="stat-count">{{ stats.total.toLocaleString() }}</span>
<span class="stat-label">Total</span>
</div>
<div class="stat-card">
<span class="stat-count stat-audit">{{ stats.audit.toLocaleString() }}</span>
<span class="stat-label">Audit</span>
</div>
<div class="stat-card">
<span class="stat-count stat-usage">{{ stats.usage.toLocaleString() }}</span>
<span class="stat-label">Usage</span>
</div>
<div class="stat-card">
<span class="stat-count stat-error">{{ stats.error.toLocaleString() }}</span>
<span class="stat-label">Error</span>
</div>
</div>
</section>
<section class="settings-section">
<h2>Filters</h2>
<div class="filter-bar">
<select v-model="category" class="filter-select">
<option value="">All categories</option>
<option value="audit">Audit</option>
<option value="usage">Usage</option>
<option value="error">Error</option>
</select>
<input
v-model="search"
type="text"
placeholder="Search logs..."
class="filter-input"
/>
<input v-model="dateFrom" type="date" class="filter-date" title="From date" />
<input v-model="dateTo" type="date" class="filter-date" title="To date" />
<button
v-if="category || search || dateFrom || dateTo"
class="btn-ghost btn-compact"
@click="clearFilters"
>
Clear
</button>
</div>
</section>
<section class="settings-section">
<div v-if="loading" class="loading-msg">Loading logs...</div>
<div v-else-if="logs.length === 0" class="empty-msg">No log entries found.</div>
<template v-else>
<table class="users-table logs-table">
<thead>
<tr>
<th>Time</th>
<th>Category</th>
<th class="hide-mobile">User</th>
<th>Action / Endpoint</th>
<th class="hide-mobile">IP</th>
<th class="hide-mobile">Status</th>
<th class="hide-mobile">Duration</th>
</tr>
</thead>
<tbody>
<template v-for="entry in logs" :key="entry.id">
<tr
class="log-row"
:class="{ 'row-expanded': expandedId === entry.id }"
@click="toggleExpand(entry.id)"
>
<td class="cell-time">{{ fmtLogStamp(entry.created_at) }}</td>
<td>
<span class="category-badge" :class="'cat-' + entry.category">
{{ entry.category }}
</span>
</td>
<td class="hide-mobile cell-user">{{ entry.username || "—" }}</td>
<td class="cell-action">
<span v-if="entry.method" class="method-tag">{{ entry.method }}</span>
{{ displayLabel(entry) }}
</td>
<td class="hide-mobile cell-ip">{{ entry.ip_address || "—" }}</td>
<td class="hide-mobile cell-status">
<span v-if="entry.status_code" :class="entry.status_code >= 400 ? 'text-error' : ''">
{{ entry.status_code }}
</span>
<span v-else></span>
</td>
<td class="hide-mobile cell-duration">
{{ entry.duration_ms != null ? entry.duration_ms + "ms" : "—" }}
</td>
</tr>
<tr v-if="expandedId === entry.id && (entry.details || entry.ip_address)" class="detail-row">
<td colspan="7">
<div v-if="entry.ip_address" class="detail-ip">IP: {{ entry.ip_address }}</div>
<pre v-if="entry.details" class="detail-json">{{ formatDetails(entry.details) }}</pre>
</td>
</tr>
</template>
</tbody>
</table>
<PaginationBar
:total="total"
:limit="limit"
:offset="offset"
@update:offset="offset = $event"
/>
</template>
</section>
</main>
</template>
<style scoped>
.logs-page {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
}
.logs-page h1 {
margin: 0 0 1.5rem;
}
.settings-section {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.settings-section h2 {
margin: 0 0 0.75rem;
font-size: 1.1rem;
}
/* Stats */
.stats-section {
padding: 1rem 1.25rem;
}
.stats-grid {
display: flex;
gap: 1rem;
}
.stat-card {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.stat-count {
font-size: 1.5rem;
font-weight: 700;
color: var(--fs-text-primary);
}
.stat-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-text-tertiary);
}
.stat-audit {
color: var(--fs-accent);
}
.stat-usage {
color: var(--fs-success);
}
.stat-error {
color: var(--fs-error);
}
/* Filters */
.filter-bar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-select,
.filter-input,
.filter-date {
padding: 0.4rem 0.6rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.85rem;
}
.filter-select {
min-width: 140px;
}
.filter-input {
flex: 1;
min-width: 150px;
}
.filter-date {
width: 140px;
}
/* Table */
.loading-msg,
.empty-msg {
text-align: center;
color: var(--fs-text-tertiary);
font-size: 0.9rem;
padding: 1rem 0;
}
.logs-table {
width: 100%;
border-collapse: collapse;
}
.logs-table th {
text-align: left;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-text-tertiary);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
}
.logs-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
font-size: 0.85rem;
}
.logs-table tbody tr:last-child td {
border-bottom: none;
}
.log-row {
cursor: pointer;
transition: background 0.1s;
}
.log-row:hover {
background: var(--fs-surface-raised);
}
.row-expanded {
background: var(--fs-surface-raised);
}
.cell-time {
white-space: nowrap;
color: var(--fs-text-tertiary);
font-size: 0.8rem;
}
.cell-user {
color: var(--fs-text-secondary);
}
.cell-action {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-status {
font-family: monospace;
font-size: 0.85rem;
}
.cell-ip {
font-family: monospace;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
white-space: nowrap;
}
.cell-duration {
color: var(--fs-text-tertiary);
font-size: 0.8rem;
white-space: nowrap;
}
.detail-ip {
font-family: monospace;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
margin-bottom: 0.4rem;
}
.text-error {
color: var(--fs-error);
}
/* Category badges */
.category-badge {
display: inline-block;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.35rem;
border-radius: var(--fs-radius-sm);
}
.cat-audit {
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
}
.cat-usage {
color: var(--fs-success);
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
}
.cat-error {
color: var(--fs-error);
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
}
/* Method tag */
.method-tag {
display: inline-block;
font-size: 0.65rem;
font-weight: 700;
font-family: monospace;
padding: 0.05rem 0.25rem;
border-radius: 3px;
background: var(--fs-surface-raised);
color: var(--fs-text-tertiary);
margin-right: 0.25rem;
}
/* Detail row */
/* `.detail-row` is deliberately bare: a `<tr>` has nothing to style that its
cells don't carry, and the row exists to scope the rule below (#2444). */
.detail-row td {
padding: 0 0.75rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
}
.detail-json {
margin: 0;
padding: 0.75rem;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.8rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
}
@media (max-width: 768px) {
.stats-grid {
flex-wrap: wrap;
}
.stat-card {
min-width: calc(50% - 0.5rem);
}
.filter-bar {
flex-direction: column;
}
.filter-select,
.filter-input,
.filter-date {
width: 100%;
}
.cell-action {
max-width: 160px;
}
}
</style>
-43
View File
@@ -626,16 +626,6 @@ onUnmounted(() => assist.clearSelection());
gap: 0.75rem;
}
.body-tabs-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
}
.editor-tabs {
display: inline-flex;
background: var(--fs-surface-page);
@@ -673,28 +663,11 @@ onUnmounted(() => assist.clearSelection());
opacity: 0;
}
.body-editor-wrap {
min-height: 200px;
}
.stream-label {
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.stream-preview {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.75rem;
background: var(--fs-surface-raised);
min-height: 200px;
}
.main-diff {
flex: 1;
min-height: 0;
}
/* Right sidebar */
.note-sidebar {
width: 280px;
@@ -721,14 +694,6 @@ onUnmounted(() => assist.clearSelection());
border-color: var(--fs-accent);
}
/* Tag suggest row inside sidebar */
.tag-suggest-row {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
}
/* Link Suggestions */
.link-suggest-field { gap: 0.4rem; }
@@ -798,14 +763,6 @@ onUnmounted(() => assist.clearSelection());
gap: 0.5rem;
}
.assist-section-title {
font-size: 0.78rem;
font-weight: 500;
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Process editor ─────────────────────────────────────── */
.ef-label {
font-family: 'Fraunces', Georgia, serif;
+29 -37
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { apiGet, apiPost } from "@/api/client";
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
import { emptyChoices, type InceptionChoices } from "@/api/inception";
import InceptionCard from "@/components/InceptionCard.vue";
import { useToastStore } from "@/stores/toast";
import { milestoneColor } from "@/utils/palette";
@@ -47,6 +49,9 @@ const newTitle = ref("");
const newDescription = ref("");
const newGoal = ref("");
const creating = ref(false);
// Step 2 of the modal (milestone 297): what the new project inherits.
const modalStep = ref<1 | 2>(1);
const newInception = ref<InceptionChoices>(emptyChoices());
const filteredProjects = computed(() => {
if (activeTab.value === "all") return projects.value;
@@ -73,6 +78,8 @@ function openNewProjectModal() {
newTitle.value = "";
newDescription.value = "";
newGoal.value = "";
modalStep.value = 1;
newInception.value = emptyChoices();
showNewProjectModal.value = true;
}
@@ -88,13 +95,15 @@ async function createProject() {
title: newTitle.value.trim(),
description: newDescription.value.trim() || undefined,
goal: newGoal.value.trim() || undefined,
// The decision rides the create: a project made here is never undecided.
inception: newInception.value,
});
projects.value.unshift(project);
showNewProjectModal.value = false;
toast.show("Project created");
router.push(`/projects/${project.id}`);
} catch {
toast.show("Failed to create project", "error");
} catch (e: unknown) {
toast.show(apiErrorMessage(e, "Failed to create project"), "error");
} finally {
creating.value = false;
}
@@ -162,7 +171,7 @@ function overallPct(project: Project): { total: number; pct: number } {
</script>
<template>
<main class="projects-list">
<main class="page-container">
<div class="page-header">
<h1>Projects</h1>
<button class="btn-primary" @click="openNewProjectModal">+ New Project</button>
@@ -266,8 +275,9 @@ function overallPct(project: Project): { total: number; pct: number } {
<teleport to="body">
<div v-if="showNewProjectModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-card">
<h3 class="modal-title">New Project</h3>
<div class="modal-field">
<h3 class="modal-title">{{ modalStep === 1 ? "New Project" : "New Project — what it inherits" }}</h3>
<InceptionCard v-if="modalStep === 2" mode="create" v-model:choices="newInception" />
<div v-if="modalStep === 1" class="modal-field">
<label>Title <span class="required">*</span></label>
<input
v-model="newTitle"
@@ -275,11 +285,11 @@ function overallPct(project: Project): { total: number; pct: number } {
class="modal-input"
placeholder="Project title"
autofocus
@keydown.enter="createProject"
@keydown.enter="modalStep = 2"
@keydown.escape="closeModal"
/>
</div>
<div class="modal-field">
<div v-if="modalStep === 1" class="modal-field">
<label>Goal</label>
<input
v-model="newGoal"
@@ -289,7 +299,7 @@ function overallPct(project: Project): { total: number; pct: number } {
@keydown.escape="closeModal"
/>
</div>
<div class="modal-field">
<div v-if="modalStep === 1" class="modal-field">
<label>Description</label>
<textarea
v-model="newDescription"
@@ -301,7 +311,17 @@ function overallPct(project: Project): { total: number; pct: number } {
</div>
<div class="modal-actions">
<button class="modal-btn" @click="closeModal">Cancel</button>
<button v-if="modalStep === 2" class="modal-btn" @click="modalStep = 1">Back</button>
<button
v-if="modalStep === 1"
class="modal-btn modal-btn-primary"
@click="modalStep = 2"
:disabled="!newTitle.trim()"
>
Next
</button>
<button
v-else
class="modal-btn modal-btn-primary"
@click="createProject"
:disabled="!newTitle.trim() || creating"
@@ -316,22 +336,6 @@ function overallPct(project: Project): { total: number; pct: number } {
</template>
<style scoped>
.projects-list {
max-width: var(--fs-layout-page-max);
margin: 2rem auto;
padding: 0 var(--fs-layout-page-pad);
overflow-x: clip;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.page-header h1 {
margin: 0;
}
/* Moss action-primary per Hybrid — list-view utility action,
not a brand moment. Empty-state .empty-action below keeps accent. */
@@ -362,21 +366,12 @@ function overallPct(project: Project): { total: number; pct: number } {
border-bottom-color: var(--fs-accent);
font-weight: 500;
}
.loading-msg,
.error-msg {
color: var(--fs-text-tertiary);
font-size: 0.9rem;
margin-top: 1rem;
}
.error-msg {
color: var(--fs-error);
}
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--fs-text-tertiary); }
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.3; }
.empty-title { font-size: 1rem; font-weight: 500; color: var(--fs-text-secondary); margin: 0 0 0.35rem; }
.empty-sub { font-size: 0.85rem; margin: 0 0 1rem; }
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--fs-action-primary); border-radius: var(--fs-radius-sm); color: var(--fs-action-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
.empty-action:hover { background: var(--fs-action-primary); color: var(--fs-text-on-action); }
@@ -579,9 +574,6 @@ function overallPct(project: Project): { total: number; pct: number } {
font-weight: 500;
color: var(--fs-text-primary);
}
.required {
color: var(--fs-error);
}
.modal-input,
.modal-textarea {
padding: 0.45rem 0.7rem;
+45 -33
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiGet, apiPatch, apiDelete, apiPost, apiPut } from "@/api/client";
import { apiGet, apiPatch, apiDelete, apiPost, apiPut, apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { useTasksStore } from "@/stores/tasks";
@@ -11,6 +11,9 @@ import ShareDialog from "@/components/ShareDialog.vue";
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue";
import InceptionCard from "@/components/InceptionCard.vue";
import { fmtDate } from "@/utils/dateFormat";
import type { InceptionDecision, InceptionRecord } from "@/api/inception";
import {
fetchDesignSystems,
setProjectDesignSystem,
@@ -50,6 +53,7 @@ interface Project {
color: string | null;
design_system_id: number | null;
forge_connection_id: number | null;
inception?: InceptionRecord | null;
permission?: string;
created_at: string;
updated_at: string;
@@ -75,6 +79,12 @@ interface NoteItem {
const route = useRoute();
const router = useRouter();
const toast = useToastStore();
function onInceptionDecided(decision: InceptionDecision) {
if (project.value) project.value.inception = decision.inception;
toast.show("Inheritance recorded");
void loadProject();
}
const tasksStore = useTasksStore();
const project = ref<Project | null>(null);
@@ -533,8 +543,7 @@ async function saveForgePin() {
if (project.value) project.value.forge_connection_id = forgePin.value;
await loadCoverage();
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toast.show(body?.error || "Failed to change the project's forge", "error");
toast.show(apiErrorMessage(e, "Failed to change the project's forge"), "error");
forgePin.value = project.value?.forge_connection_id ?? null;
} finally {
savingForgePin.value = false;
@@ -631,7 +640,7 @@ async function confirmDelete() {
</script>
<template>
<main class="project-view">
<main class="page-container">
<!-- Nav bar -->
<div class="page-header">
@@ -695,6 +704,26 @@ async function confirmDelete() {
</p>
</div>
<!-- Inception (milestone 297): the owner of an undecided project is asked
what it inherits; once recorded, one line says what was decided. -->
<InceptionCard
v-if="project.inception == null && isProjectOwner"
mode="decide"
:project-id="projectId"
@decided="onInceptionDecided"
/>
<p v-else-if="project.inception" class="inception-line">
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
<template v-if="project.inception.choices.exclude_always_on_rulebooks.length">
· excludes {{ project.inception.choices.exclude_always_on_rulebooks.length }} always-on rulebook(s)
</template>
<template v-if="project.inception.choices.subscribe_rulebooks.length">
· subscribes {{ project.inception.choices.subscribe_rulebooks.length }}
</template>
· design system {{ project.inception.choices.design_system_id ? "#" + project.inception.choices.design_system_id : "none" }}
<template v-if="project.inception.choices.seed_systems"> · Systems seeded</template>
</p>
<!-- Summary stat chips -->
<div v-if="project.summary" class="summary-stats">
<div class="stat-chip stat-todo">
@@ -844,15 +873,15 @@ async function confirmDelete() {
paragraph in practice this one showed as "Maintain Scribe as
the reliabl" and gave no way to read the rest without arrowing
through it. -->
<textarea v-model="editGoal" class="edit-textarea" rows="4" placeholder="What are you trying to achieve?"></textarea>
<textarea v-model="editGoal" class="fs-input edit-textarea" rows="4" placeholder="What are you trying to achieve?"></textarea>
</div>
<div class="edit-field">
<label class="edit-label">Description</label>
<textarea v-model="editDescription" class="edit-textarea" rows="6" placeholder="Optional description..."></textarea>
<textarea v-model="editDescription" class="fs-input edit-textarea" rows="6" placeholder="Optional description..."></textarea>
</div>
<div class="edit-field">
<label class="edit-label">Status</label>
<select v-model="editStatus" class="edit-select">
<select v-model="editStatus" class="fs-input edit-select">
<option value="active">Active</option>
<option value="paused">Paused</option>
<option value="completed">Completed</option>
@@ -861,7 +890,7 @@ async function confirmDelete() {
</div>
<div v-if="designSystems.length" class="edit-field">
<label class="edit-label" for="project-design-system">Design system</label>
<select id="project-design-system" v-model="editDesignSystemId" class="edit-select">
<select id="project-design-system" v-model="editDesignSystemId" class="fs-input edit-select">
<option :value="null">None</option>
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
</select>
@@ -1172,19 +1201,9 @@ async function confirmDelete() {
<style scoped>
/* ── Layout ─────────────────────────────────────────────────── */
.project-view {
max-width: var(--fs-layout-page-max);
margin: 2rem auto;
padding: 0 var(--fs-layout-page-pad);
overflow-x: clip;
}
/* ── Nav bar ─────────────────────────────────────────────────── */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
margin-bottom: 1.5rem; /* roomier than the shared recipe */
}
.page-header-actions { display: flex; gap: 0.5rem; align-items: center; }
.plan-title-input {
@@ -1197,6 +1216,7 @@ async function confirmDelete() {
min-width: 200px;
}
.inception-line { margin: 0 0 1rem; color: var(--fs-text-secondary); font-size: 0.85rem; }
.project-title-input {
flex: 1;
font-size: 1.75rem;
@@ -1378,7 +1398,7 @@ async function confirmDelete() {
/* `minmax(0, 1fr)`, not `1fr`. A bare `1fr` track has an AUTO minimum, so it
cannot shrink below its content — one wide descendant anywhere in the
content column widens the whole column past the grid, and everything inside
it then overflows the page and gets cut by `.project-view`'s
it then overflows the page and gets cut by `.page-container`'s
`overflow-x: clip`.
This is the same property the header nav relies on and wants (neither side
squeezed under its content); here it is exactly wrong, because the column
@@ -1422,18 +1442,10 @@ async function confirmDelete() {
text-transform: uppercase;
letter-spacing: 0.03em;
}
.edit-input, .edit-textarea, .edit-select {
padding: 0.4rem 0.6rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
width: 100%;
}
.edit-input:focus, .edit-textarea:focus, .edit-select:focus { outline: none; border-color: var(--fs-accent); }
/* The input itself is the .fs-input canon (components.css); only the
layout remainder lives here. */
.edit-textarea,
.edit-select { box-sizing: border-box; width: 100%; }
.edit-textarea { resize: vertical; }
/* Save panel: Moss action-primary per Hybrid rule */
@@ -1802,7 +1814,7 @@ async function confirmDelete() {
.note-title { font-weight: 500; min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.note-date { font-size: 0.75rem; color: var(--fs-text-tertiary); flex-shrink: 0; }
.empty-msg { color: var(--fs-text-tertiary); font-size: 0.875rem; text-align: center; padding: 1rem; }
.empty-msg { text-align: center; padding: 1rem; } /* remainder over the shared recipe */
/* Deliberately NOT styled like .empty-msg: "no tasks" and "the tasks did not
load" look identical to a user, and conflating them is what let a silent
failure read as an empty project. */
+48 -514
View File
@@ -780,8 +780,7 @@ async function saveConnection() {
connFormOpen.value = false;
await loadForgeConnections();
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to save forge connection", "error");
toastStore.show(apiErrorMessage(e, "Failed to save forge connection"), "error");
} finally {
savingConn.value = false;
}
@@ -793,8 +792,7 @@ async function removeConnection(id: number) {
connTestResult.value = null;
await loadForgeConnections();
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to delete forge connection", "error");
toastStore.show(apiErrorMessage(e, "Failed to delete forge connection"), "error");
}
}
@@ -810,10 +808,9 @@ async function testConnection(id: number) {
message: `Connected — ${res.version}, authenticated as ${res.username}`,
};
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
connTestResult.value = {
id, ok: false,
message: body?.error || "Connection test failed",
message: apiErrorMessage(e, "Connection test failed"),
};
} finally {
testingConnId.value = 0;
@@ -830,8 +827,7 @@ async function saveForgeWebhook() {
forgeWebhookSaved.value = true;
setTimeout(() => (forgeWebhookSaved.value = false), 2000);
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to save webhook secret", "error");
toastStore.show(apiErrorMessage(e, "Failed to save webhook secret"), "error");
} finally {
savingForgeWebhook.value = false;
}
@@ -864,8 +860,7 @@ async function saveMarketplaceUrl() {
marketplaceUrlSaved.value = true;
setTimeout(() => (marketplaceUrlSaved.value = false), 2000);
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to save marketplace URL", "error");
toastStore.show(apiErrorMessage(e, "Failed to save marketplace URL"), "error");
} finally {
savingMarketplaceUrl.value = false;
}
@@ -882,8 +877,7 @@ async function saveDbMaintenance() {
dbMaintSaved.value = true;
setTimeout(() => (dbMaintSaved.value = false), 2000);
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to save maintenance settings", "error");
toastStore.show(apiErrorMessage(e, "Failed to save maintenance settings"), "error");
} finally {
savingDbMaint.value = false;
}
@@ -912,8 +906,7 @@ async function runDbMaintenanceNow() {
);
await loadDbHealth(); // reflect the dead-tuple drop
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Maintenance run failed", "error");
toastStore.show(apiErrorMessage(e, "Maintenance run failed"), "error");
} finally {
runningDbMaint.value = false;
}
@@ -1144,8 +1137,7 @@ async function sendInvite() {
inviteEmail.value = "";
await fetchInvitations();
} catch (e: unknown) {
const body = (e as { body?: { error?: string } })?.body;
toastStore.show(body?.error || "Failed to send invitation", "error");
toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
} finally {
sendingInvite.value = false;
}
@@ -1196,8 +1188,7 @@ async function deleteUser(userId: number) {
users.value = users.value.filter((u) => u.id !== userId);
toastStore.show("User deleted");
} catch (e: unknown) {
const body = (e as { body?: { error?: string } })?.body;
toastStore.show(body?.error || "Failed to delete user", "error");
toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
} finally {
deleting.value = null;
}
@@ -1245,7 +1236,7 @@ async function deleteUser(userId: number) {
id="user-timezone"
v-model="userTimezone"
type="text"
class="input"
class="fs-input input"
placeholder="e.g. America/New_York"
/>
<button class="btn-secondary" type="button" @click="detectTimezone">Detect</button>
@@ -1272,7 +1263,7 @@ async function deleteUser(userId: number) {
type="number"
min="0"
step="1"
class="input"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">Set to <strong>0</strong> to keep deleted items forever (never auto-purge).</p>
@@ -1310,7 +1301,7 @@ async function deleteUser(userId: number) {
min="0"
max="1"
step="0.05"
class="input"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">Minimum similarity to surface a note. Higher = stricter (fewer, more certain). Deliberately above the 0.45 used for searches you trigger yourself.</p>
@@ -1324,7 +1315,7 @@ async function deleteUser(userId: number) {
min="1"
max="10"
step="1"
class="input"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">Ceiling on titles surfaced at once (110).</p>
@@ -1354,7 +1345,7 @@ async function deleteUser(userId: number) {
min="0"
max="1"
step="0.01"
class="input"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">
@@ -1383,7 +1374,7 @@ async function deleteUser(userId: number) {
min="0"
max="1"
step="0.01"
class="input"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">
@@ -1403,7 +1394,7 @@ async function deleteUser(userId: number) {
min="0"
max="1"
step="0.01"
class="input"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">
@@ -1424,7 +1415,7 @@ async function deleteUser(userId: number) {
min="0"
max="1"
step="0.01"
class="input"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">
@@ -1466,7 +1457,7 @@ async function deleteUser(userId: number) {
v-model="newEmail"
type="email"
placeholder="you@example.com"
class="input"
class="fs-input input"
/>
</div>
<div class="field">
@@ -1476,7 +1467,7 @@ async function deleteUser(userId: number) {
v-model="emailPassword"
type="password"
autocomplete="current-password"
class="input"
class="fs-input input"
/>
<p class="field-hint">Required to confirm the change.</p>
</div>
@@ -1500,7 +1491,7 @@ async function deleteUser(userId: number) {
v-model="currentPassword"
type="password"
autocomplete="current-password"
class="input"
class="fs-input input"
/>
</div>
<div class="field">
@@ -1510,7 +1501,7 @@ async function deleteUser(userId: number) {
v-model="newPassword"
type="password"
autocomplete="new-password"
class="input"
class="fs-input input"
/>
<p class="field-hint">Must be at least 8 characters</p>
</div>
@@ -1521,7 +1512,7 @@ async function deleteUser(userId: number) {
v-model="confirmNewPassword"
type="password"
autocomplete="new-password"
class="input"
class="fs-input input"
:class="{ 'input-error': confirmNewPassword && newPassword !== confirmNewPassword }"
/>
<p v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="error-hint">
@@ -1564,20 +1555,20 @@ async function deleteUser(userId: number) {
<div class="assistant-grid">
<div class="field">
<label>Display Name</label>
<input v-model="profile.display_name" type="text" class="input" placeholder="e.g. Alex" />
<input v-model="profile.display_name" type="text" class="fs-input input" placeholder="e.g. Alex" />
<p class="field-hint">How the assistant addresses you.</p>
</div>
<div class="field">
<label>Job Title</label>
<input v-model="profile.job_title" type="text" class="input" placeholder="e.g. Product Manager" />
<input v-model="profile.job_title" type="text" class="fs-input input" placeholder="e.g. Product Manager" />
</div>
<div class="field">
<label>Industry</label>
<input v-model="profile.industry" type="text" class="input" placeholder="e.g. Technology" />
<input v-model="profile.industry" type="text" class="fs-input input" placeholder="e.g. Technology" />
</div>
<div class="field">
<label>Expertise Level</label>
<select v-model="profile.expertise_level" class="input">
<select v-model="profile.expertise_level" class="fs-input input">
<option value="novice">Novice explain things simply</option>
<option value="intermediate">Intermediate balanced explanations</option>
<option value="expert">Expert assume deep knowledge</option>
@@ -1596,7 +1587,7 @@ async function deleteUser(userId: number) {
<div class="assistant-grid">
<div class="field">
<label>Response Style</label>
<select v-model="profile.response_style" class="input">
<select v-model="profile.response_style" class="fs-input input">
<option value="concise">Concise short and direct</option>
<option value="balanced">Balanced default</option>
<option value="detailed">Detailed thorough explanations</option>
@@ -1604,7 +1595,7 @@ async function deleteUser(userId: number) {
</div>
<div class="field">
<label>Tone</label>
<select v-model="profile.tone" class="input">
<select v-model="profile.tone" class="fs-input input">
<option value="casual">Casual friendly and relaxed</option>
<option value="professional">Professional formal and precise</option>
<option value="technical">Technical jargon-friendly</option>
@@ -1646,11 +1637,11 @@ async function deleteUser(userId: number) {
<div class="assistant-grid" style="margin-top:0.75rem">
<div class="field">
<label>Start Time</label>
<input v-model="profile.work_schedule.start" type="time" class="input" />
<input v-model="profile.work_schedule.start" type="time" class="fs-input input" />
</div>
<div class="field">
<label>End Time</label>
<input v-model="profile.work_schedule.end" type="time" class="input" />
<input v-model="profile.work_schedule.end" type="time" class="fs-input input" />
</div>
</div>
<div class="actions">
@@ -1706,7 +1697,7 @@ async function deleteUser(userId: number) {
<input
v-model="searchQuery"
type="text"
class="input"
class="fs-input input"
placeholder="Enter a search query..."
@keydown="onSearchKeydown"
/>
@@ -1772,17 +1763,17 @@ async function deleteUser(userId: number) {
<div v-if="connFormOpen" class="smtp-grid">
<div class="field">
<label for="conn-kind">Forge</label>
<select id="conn-kind" v-model="connForm.kind" class="input">
<select id="conn-kind" v-model="connForm.kind" class="fs-input input">
<option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
</select>
</div>
<div class="field">
<label for="conn-base-url">Base URL</label>
<input id="conn-base-url" v-model="connForm.base_url" type="text" placeholder="https://git.example.com" class="input" />
<input id="conn-base-url" v-model="connForm.base_url" type="text" placeholder="https://git.example.com" class="fs-input input" />
</div>
<div class="field">
<label for="conn-token">API Token (read scope)</label>
<input id="conn-token" v-model="connForm.token" type="password" class="input" />
<input id="conn-token" v-model="connForm.token" type="password" class="fs-input input" />
</div>
</div>
<div class="actions">
@@ -2072,7 +2063,7 @@ async function deleteUser(userId: number) {
v-model="baseUrl"
type="url"
placeholder="https://notes.example.com"
class="input"
class="fs-input input"
/>
</div>
<div class="actions">
@@ -2097,7 +2088,7 @@ async function deleteUser(userId: number) {
v-model="adminMarketplaceUrl"
type="url"
placeholder="https://git.example.com/you/Scribe.git"
class="input"
class="fs-input input"
/>
</div>
<div class="actions">
@@ -2124,7 +2115,7 @@ async function deleteUser(userId: number) {
</div>
<div class="field url-field">
<label for="db-maint-hour">Run hour (UTC)</label>
<select id="db-maint-hour" v-model.number="dbMaintHour" class="input">
<select id="db-maint-hour" v-model.number="dbMaintHour" class="fs-input input">
<option v-for="h in 24" :key="h - 1" :value="h - 1">
{{ String(h - 1).padStart(2, '0') }}:00
</option>
@@ -2194,27 +2185,27 @@ async function deleteUser(userId: number) {
<div class="smtp-grid">
<div class="field">
<label for="smtp-host">SMTP Host</label>
<input id="smtp-host" v-model="smtp.smtp_host" type="text" placeholder="smtp.example.com" class="input" />
<input id="smtp-host" v-model="smtp.smtp_host" type="text" placeholder="smtp.example.com" class="fs-input input" />
</div>
<div class="field">
<label for="smtp-port">Port</label>
<input id="smtp-port" v-model="smtp.smtp_port" type="text" placeholder="587" class="input" />
<input id="smtp-port" v-model="smtp.smtp_port" type="text" placeholder="587" class="fs-input input" />
</div>
<div class="field">
<label for="smtp-username">Username</label>
<input id="smtp-username" v-model="smtp.smtp_username" type="text" class="input" />
<input id="smtp-username" v-model="smtp.smtp_username" type="text" class="fs-input input" />
</div>
<div class="field">
<label for="smtp-password">Password</label>
<input id="smtp-password" v-model="smtp.smtp_password" type="password" class="input" />
<input id="smtp-password" v-model="smtp.smtp_password" type="password" class="fs-input input" />
</div>
<div class="field">
<label for="smtp-from-address">From Address</label>
<input id="smtp-from-address" v-model="smtp.smtp_from_address" type="email" placeholder="noreply@example.com" class="input" />
<input id="smtp-from-address" v-model="smtp.smtp_from_address" type="email" placeholder="noreply@example.com" class="fs-input input" />
</div>
<div class="field">
<label for="smtp-from-name">From Name</label>
<input id="smtp-from-name" v-model="smtp.smtp_from_name" type="text" placeholder="Fabled Scribe" class="input" />
<input id="smtp-from-name" v-model="smtp.smtp_from_name" type="text" placeholder="Fabled Scribe" class="fs-input input" />
</div>
</div>
<div class="checkbox-field">
@@ -2237,7 +2228,7 @@ async function deleteUser(userId: number) {
v-model="testRecipient"
type="email"
placeholder="test@example.com"
class="input"
class="fs-input input"
/>
<button class="btn-primary" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
{{ sendingTest ? "Sending..." : "Send Test" }}
@@ -2258,7 +2249,7 @@ async function deleteUser(userId: number) {
<div class="smtp-grid">
<div class="field">
<label for="forge-webhook-secret">Webhook Secret</label>
<input id="forge-webhook-secret" v-model="forgeWebhookSecret" type="password" class="input" />
<input id="forge-webhook-secret" v-model="forgeWebhookSecret" type="password" class="fs-input input" />
</div>
</div>
<div class="actions">
@@ -2304,7 +2295,7 @@ async function deleteUser(userId: number) {
v-model="inviteEmail"
type="email"
placeholder="Email address"
class="input invite-input"
class="fs-input input invite-input"
required
:disabled="sendingInvite"
/>
@@ -2654,70 +2645,6 @@ async function deleteUser(userId: number) {
line-height: 1.5;
}
/* ── Model Management ───────────────────────────────────────── */
.model-mgmt-header {
display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.75rem;
}
.model-mgmt-header h2 { margin: 0; }
.model-mgmt-header .section-desc { margin: 0.25rem 0 0; }
.model-list { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.75rem; }
.model-row {
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
padding: 0.45rem 0.6rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: 6px;
}
.model-row-info { display: flex; align-items: center; gap: 0.4rem; min-width: 0; flex: 1; }
.model-name { font-size: 0.88rem; font-weight: 500; font-family: monospace; }
.model-badge {
font-size: 0.68rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-weight: 500; white-space: nowrap;
}
.model-badge--loaded { background: color-mix(in srgb, #22c55e 18%, transparent); color: #22c55e; }
.model-badge--default { background: color-mix(in srgb, var(--fs-accent) 18%, transparent); color: var(--fs-accent); }
.model-row-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.model-size { font-size: 0.78rem; color: var(--fs-text-tertiary); }
.model-delete-btn {
background: none; border: none; cursor: pointer; color: var(--fs-text-tertiary);
font-size: 0.8rem; padding: 0.2rem 0.35rem; border-radius: 3px; line-height: 1;
transition: color 0.15s, background 0.15s;
}
.model-delete-btn:hover:not(:disabled) { color: var(--fs-action-destructive); background: color-mix(in srgb, var(--fs-action-destructive) 10%, transparent); }
.model-delete-btn:disabled { opacity: 0.4; cursor: default; }
.model-pull-form { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
.model-pull-form .input { flex: 1; }
.model-suggestions { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.4rem; }
.suggestions-label { font-size: 0.75rem; color: var(--fs-text-tertiary); white-space: nowrap; }
.suggestion-chip {
font-size: 0.72rem; padding: 0.15rem 0.5rem; border-radius: 4px;
border: 1px solid var(--fs-border-color); background: var(--fs-surface-raised);
cursor: pointer; font-family: monospace; color: var(--fs-text-primary);
transition: border-color 0.12s, background 0.12s;
}
.suggestion-chip:hover:not(:disabled) { border-color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised)); }
.suggestion-chip:disabled { opacity: 0.4; cursor: default; }
.model-pull-progress { margin-top: 0.6rem; }
.pull-status { font-size: 0.8rem; color: var(--fs-text-tertiary); margin-bottom: 0.25rem; }
.pull-bar-track {
height: 4px; background: var(--fs-border-color); border-radius: 2px; overflow: hidden;
}
.pull-bar-fill {
height: 100%; background: var(--fs-accent); border-radius: 2px;
transition: width 0.3s ease;
}
.pull-bar-indeterminate {
height: 4px; background: var(--fs-border-color); border-radius: 2px;
position: relative; overflow: hidden;
}
.pull-bar-indeterminate::after {
content: ""; position: absolute; top: 0; left: -40%;
width: 40%; height: 100%; background: var(--fs-accent); border-radius: 2px;
animation: indeterminate 1.2s ease-in-out infinite;
}
@keyframes indeterminate {
0% { left: -40%; } 100% { left: 100%; }
}
/* Assistant — 2-col internal grid */
.assistant-grid {
display: grid;
@@ -2740,30 +2667,10 @@ async function deleteUser(userId: number) {
margin-bottom: 0.35rem;
color: var(--fs-text-primary);
}
/* remainder over .fs-input (components.css, canon #2336; m302) */
.input {
width: 100%;
padding: 0.45rem 0.7rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.9rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
font-family: inherit;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
}
.field-hint {
margin: 0.3rem 0 0;
font-size: 0.78rem;
color: var(--fs-text-tertiary);
}
.field-hint-warn {
display: block;
margin-top: 0.3rem;
color: #f59e0b;
}
.actions {
display: flex;
@@ -2833,16 +2740,6 @@ async function deleteUser(userId: number) {
color: var(--fs-error);
}
.retention-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.4rem;
}
.retention-input {
width: 6rem;
}
/* Data buttons */
.data-actions {
display: flex;
@@ -2969,42 +2866,6 @@ async function deleteUser(userId: number) {
max-width: 480px;
}
/* Push notifications */
.push-unsupported {
font-size: 0.875rem;
color: var(--fs-text-tertiary);
}
.push-status-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
font-size: 0.875rem;
}
.push-status-label {
color: var(--fs-text-secondary);
font-weight: 500;
}
.push-permission-badge,
.push-sub-badge {
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
color: var(--fs-text-tertiary);
}
.perm-granted { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success); }
.perm-denied { background: color-mix(in srgb, var(--fs-error) 15%, transparent); color: var(--fs-error); }
.sub-active { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success); }
.push-error {
font-size: 0.82rem;
color: var(--fs-error);
margin: 0.25rem 0 0;
}
@media (max-width: 768px) {
.settings-root {
flex-direction: column;
@@ -3172,7 +3033,7 @@ async function deleteUser(userId: number) {
.cell-status { font-family: monospace; font-size: 0.85rem; }
.cell-duration { color: var(--fs-text-tertiary); font-size: 0.8rem; white-space: nowrap; }
.text-error { color: var(--fs-error); }
/* Bare by design, like LogsView's twin of this: a `<tr>` has nothing to style
/* Bare by design: a `<tr>` has nothing to style
that its cells don't carry (#2444). */
.detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--fs-border-color); }
.detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--fs-text-tertiary); margin-bottom: 0.4rem; }
@@ -3199,25 +3060,6 @@ async function deleteUser(userId: number) {
margin-right: 0.25rem;
}
/* ── About / version ─────────────────────────────────────────── */
.version-line {
margin: 0;
font-size: 0.9rem;
color: var(--fs-text-primary);
display: flex;
align-items: center;
gap: 0.6rem;
}
.version-badge {
font-family: ui-monospace, monospace;
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-accent);
border-radius: 4px;
color: var(--fs-accent);
}
/* ── Groups tab ──────────────────────────────────────────────── */
/* Moss action-primary per Hybrid */
@@ -3302,7 +3144,6 @@ async function deleteUser(userId: number) {
flex-shrink: 0;
}
.member-search-wrap {
flex: 1;
position: relative;
@@ -3335,7 +3176,6 @@ async function deleteUser(userId: number) {
}
.member-result-item:hover { background: var(--fs-surface-hover); }
.member-result-name { font-weight: 500; font-size: 0.85rem; }
.member-result-email { color: var(--fs-text-tertiary); font-size: 0.78rem; }
.role-select {
padding: 0.4rem 0.5rem;
@@ -3385,76 +3225,6 @@ async function deleteUser(userId: number) {
padding: 0.25rem 0.5rem;
}
/* Profile — Locations + Journal sections */
.location-row {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
.geo-msg {
font-size: 0.78rem;
margin: 0.2rem 0 0;
}
.geo-ok { color: var(--fs-success); }
.geo-error { color: var(--fs-error); }
.geo-pending { color: var(--fs-text-tertiary); }
.unit-toggle {
display: flex;
gap: 0;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
overflow: hidden;
width: fit-content;
margin-top: 0.25rem;
}
.unit-btn {
padding: 0.4rem 1rem;
background: var(--fs-surface-raised);
color: var(--fs-text-tertiary);
font-size: 0.85rem;
cursor: pointer;
border: none;
font-family: inherit;
transition: background 0.15s, color 0.15s;
}
.unit-btn:not(:last-child) {
border-right: 1px solid var(--fs-border-color);
}
.unit-btn.active {
background: var(--fs-action-primary);
color: var(--fs-text-on-action);
}
.unit-btn:hover:not(.active) {
color: var(--fs-text-primary);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
}
.checkbox-label input[type="checkbox"] {
cursor: pointer;
}
.time-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.time-input {
width: 4rem;
text-align: center;
}
.time-sep {
font-size: 1.1rem;
color: var(--fs-text-tertiary);
}
.time-sep--quiet { font-size: 0.9rem; }
/* API Keys tab */
.api-key-create-form {
display: flex;
@@ -3517,24 +3287,6 @@ async function deleteUser(userId: number) {
.scope-badge.write { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; }
.settings-empty { opacity: 0.5; margin-top: 1rem; }
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }
/* Scribe MCP section */
.mcp-status { opacity: 0.6; font-size: 0.9rem; }
.mcp-unavailable p { opacity: 0.7; }
.mcp-available { display: flex; flex-direction: column; gap: 1.25rem; }
.mcp-pkg-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.65rem 0.9rem;
background: color-mix(in srgb, var(--fs-accent) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
border-radius: 8px;
}
.mcp-pkg-name { font-family: monospace; font-size: 0.9rem; flex: 1; }
.mcp-install-steps h3 { font-size: 0.95rem; font-weight: 500; margin-bottom: 0.75rem; }
.mcp-install-steps ol { padding-left: 1.25rem; display: flex; flex-direction: column; gap: 0.75rem; }
.mcp-install-steps li { line-height: 1.6; font-size: 0.9rem; }
.mcp-code {
margin-top: 0.4rem;
padding: 0.55rem 0.75rem;
@@ -3623,207 +3375,6 @@ async function deleteUser(userId: number) {
opacity: 0.7;
line-height: 1.5;
}
.mcp-other p { font-size: 0.9rem; line-height: 1.6; }
.mcp-plain-list {
list-style: disc;
padding-left: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.9rem;
line-height: 1.6;
}
/* Voice tab */
.voice-status-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
/* Voice Library (admin) */
.voice-library-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.voice-library-toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
margin: 1rem 0;
}
.voice-library-toolbar .input {
flex: 1;
}
.voice-library-list {
list-style: none;
margin: 0;
padding: 0;
border-top: 1px solid var(--fs-border-color);
max-height: 480px;
overflow-y: auto;
}
.voice-library-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.65rem 0.25rem;
border-bottom: 1px solid var(--fs-border-color);
}
.voice-library-meta {
min-width: 0;
flex: 1;
}
.voice-library-id {
font-family: var(--fs-font-mono);
font-size: 0.9rem;
font-weight: 500;
}
.voice-library-sub {
color: var(--fs-text-tertiary);
font-size: 0.8rem;
margin-top: 0.15rem;
}
.voice-library-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.voice-library-empty {
padding: 1rem 0;
color: var(--fs-text-tertiary);
text-align: center;
font-style: italic;
}
.voice-badge {
font-size: 0.7rem;
font-weight: 500;
padding: 0.15rem 0.5rem;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.voice-badge--bundled {
background: color-mix(in srgb, var(--fs-text-tertiary) 10%, transparent);
color: var(--fs-text-tertiary);
border: 1px solid var(--fs-border-color);
}
.status-badge {
font-size: 0.75rem;
font-weight: 500;
padding: 0.15rem 0.55rem;
border-radius: 999px;
}
.status-on {
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
color: var(--fs-success);
border: 1px solid color-mix(in srgb, var(--fs-success) 40%, transparent);
}
.status-off {
background: color-mix(in srgb, var(--fs-text-tertiary) 10%, transparent);
color: var(--fs-text-tertiary);
border: 1px solid var(--fs-border-color);
}
.radio-group {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-top: 0.35rem;
}
.radio-option {
display: flex;
align-items: flex-start;
gap: 0.55rem;
cursor: pointer;
}
.radio-option input[type="radio"] { margin-top: 0.2rem; flex-shrink: 0; }
.radio-option span { display: flex; flex-direction: column; gap: 0.15rem; }
.radio-option strong { font-size: 0.875rem; }
.range-input {
width: 100%;
max-width: 360px;
accent-color: var(--fs-accent);
margin: 0.35rem 0 0.2rem;
}
.range-labels {
display: flex;
justify-content: space-between;
max-width: 360px;
font-size: 0.75rem;
color: var(--fs-text-tertiary);
}
.form-actions {
margin-top: 1rem;
}
.voice-admin-spinner {
display: inline-flex;
gap: 3px;
align-items: center;
margin-left: 0.4rem;
vertical-align: middle;
}
.voice-admin-spinner span {
display: inline-block;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--fs-text-tertiary);
animation: va-dot-bounce 1.2s ease-in-out infinite;
}
.voice-admin-spinner span:nth-child(2) { animation-delay: 0.2s; }
.voice-admin-spinner span:nth-child(3) { animation-delay: 0.4s; }
/* ── Voice blend ────────────────────────────────────────────────────────── */
.blend-slots {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.blend-slot {
background: color-mix(in srgb, var(--fs-surface-hover) 60%, transparent);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.blend-voice-select {
width: 100%;
}
.blend-weight-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.blend-weight-slider {
flex: 1;
}
.blend-weight-label {
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
min-width: 2.5rem;
text-align: right;
color: var(--fs-text-secondary);
}
.blend-actions {
margin-top: 0.25rem;
}
.toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.9rem;
}
/* ── Profile tab ─────────────────────────────────────────────────────────── */
.day-picker {
@@ -3850,21 +3401,4 @@ async function deleteUser(userId: number) {
color: var(--fs-accent);
font-weight: 500;
}
.learned-summary {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-left: 3px solid var(--fs-accent);
border-radius: 8px;
padding: 0.85rem 1rem;
font-size: 0.9rem;
line-height: 1.55;
color: var(--fs-text-primary);
white-space: pre-wrap;
margin-bottom: 0.75rem;
}
.learned-empty {
font-size: 0.85rem;
color: var(--fs-text-tertiary);
margin-bottom: 0.75rem;
}
</style>
+1 -3
View File
@@ -98,7 +98,7 @@ onMounted(async () => {
}
.page-header {
margin-bottom: 2rem;
margin-bottom: 2rem; /* roomier than the shared recipe */
}
.page-title {
@@ -247,8 +247,6 @@ onMounted(async () => {
.perm-admin { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning); }
.empty-msg {
color: var(--fs-text-tertiary);
font-size: 0.88rem;
margin: 0;
padding: 1rem 0;
}
+1 -7
View File
@@ -220,14 +220,8 @@ async function confirmDelete() {
color: var(--fs-accent);
}
.state-msg {
color: var(--fs-text-tertiary);
font-size: 0.9rem;
margin-top: 1rem;
}
.state-msg,
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin-top: 1rem;
}
+12 -39
View File
@@ -227,7 +227,7 @@ function cancel() {
ref="nameRef"
v-model="form.name"
type="text"
class="input mono"
class="fs-input input mono"
placeholder="useDebouncedRef"
@keydown.escape="cancel"
/>
@@ -239,7 +239,7 @@ function cancel() {
id="sn-when"
v-model="form.when_to_use"
type="text"
class="input"
class="fs-input input"
placeholder="Debounce a reactive ref that updates too often"
@keydown.escape="cancel"
/>
@@ -253,7 +253,7 @@ function cancel() {
id="sn-lang"
v-model="form.language"
type="text"
class="input"
class="fs-input input"
placeholder="typescript"
@keydown.escape="cancel"
/>
@@ -264,7 +264,7 @@ function cancel() {
id="sn-sig"
v-model="form.signature"
type="text"
class="input mono"
class="fs-input input mono"
placeholder="useDebouncedRef(value, ms)"
@keydown.escape="cancel"
/>
@@ -277,9 +277,9 @@ function cancel() {
<span class="hint-inline"> where the reference implementation(s) live; a merged snippet keeps every call site</span>
</legend>
<div v-for="(loc, i) in locations" :key="i" class="loc-row">
<input v-model="loc.repo" type="text" class="input mono" placeholder="repo" aria-label="Repo" @keydown.escape="cancel" />
<input v-model="loc.path" type="text" class="input mono" placeholder="path" aria-label="Path" @keydown.escape="cancel" />
<input v-model="loc.symbol" type="text" class="input mono" placeholder="symbol" aria-label="Symbol" @keydown.escape="cancel" />
<input v-model="loc.repo" type="text" class="fs-input input mono" placeholder="repo" aria-label="Repo" @keydown.escape="cancel" />
<input v-model="loc.path" type="text" class="fs-input input mono" placeholder="path" aria-label="Path" @keydown.escape="cancel" />
<input v-model="loc.symbol" type="text" class="fs-input input mono" placeholder="symbol" aria-label="Symbol" @keydown.escape="cancel" />
<button
type="button"
class="loc-remove"
@@ -296,7 +296,7 @@ function cancel() {
<textarea
id="sn-code"
v-model="form.code"
class="input mono code-area"
class="fs-input input mono code-area"
rows="14"
spellcheck="false"
placeholder="Paste the reusable implementation…"
@@ -309,7 +309,7 @@ function cancel() {
id="sn-tags"
v-model="tagsText"
type="text"
class="input"
class="fs-input input"
placeholder="composable, ui (comma-separated)"
@keydown.escape="cancel"
/>
@@ -383,15 +383,6 @@ function cancel() {
color: var(--fs-accent);
}
.state-msg {
color: var(--fs-text-tertiary);
font-size: 0.9rem;
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
}
.form {
display: flex;
flex-direction: column;
@@ -408,18 +399,12 @@ function cancel() {
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.field-row.three {
grid-template-columns: 1fr 1.4fr 1fr;
}
.field label,
.location-set legend {
font-size: 0.8rem;
font-weight: 500;
color: var(--fs-text-primary);
}
.required {
color: var(--fs-error);
}
.hint {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
@@ -430,21 +415,10 @@ function cancel() {
color: var(--fs-text-tertiary);
}
/* remainder over .fs-input (components.css, canon #2336; m302) */
.input {
padding: 0.5rem 0.7rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.9rem;
font-family: inherit;
box-sizing: border-box;
width: 100%;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
box-shadow: var(--fs-focus-ring);
box-sizing: border-box;
}
.mono {
font-family: var(--fs-font-mono);
@@ -574,8 +548,7 @@ function cancel() {
}
@media (max-width: 600px) {
.field-row,
.field-row.three {
.field-row {
grid-template-columns: 1fr;
}
}
+3 -78
View File
@@ -273,7 +273,7 @@ function usageTitle(s: SnippetListItem): string {
</script>
<template>
<main class="snippets-list">
<main class="page-container">
<div class="page-header">
<h1>Snippets</h1>
<div class="header-actions">
@@ -517,22 +517,10 @@ function usageTitle(s: SnippetListItem): string {
</main>
</template>
<style src="@/assets/dup-report.css" />
<style scoped>
.snippets-list {
max-width: var(--fs-layout-page-max);
margin: 2rem auto;
padding: 0 var(--fs-layout-page-pad);
overflow-x: clip;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.35rem;
}
.page-header h1 {
margin: 0;
margin-bottom: 0.35rem; /* tighter than the shared recipe: .page-sub follows */
}
.page-sub {
margin: 0 0 1.25rem;
@@ -622,8 +610,6 @@ function usageTitle(s: SnippetListItem): string {
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin-top: 1rem;
}
@@ -638,15 +624,7 @@ function usageTitle(s: SnippetListItem): string {
margin-bottom: 0.75rem;
opacity: 0.35;
}
.empty-title {
font-size: 1rem;
font-weight: 500;
color: var(--fs-text-secondary);
margin: 0 0 0.35rem;
}
.empty-sub {
font-size: 0.85rem;
margin: 0 0 1rem;
max-width: 44ch;
margin-inline: auto;
line-height: 1.5;
@@ -764,59 +742,6 @@ function usageTitle(s: SnippetListItem): string {
color: var(--fs-text-tertiary);
}
/* Near-duplicate report */
.dup-panel {
margin-bottom: 1.25rem;
padding: 0.85rem 1rem;
border: 1px solid var(--fs-border-color);
border-radius: 8px;
background: var(--fs-surface-hover);
}
.dup-empty,
.dup-head {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--fs-text-tertiary);
}
.dup-empty {
margin-bottom: 0;
}
.dup-group {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.5rem 0;
border-top: 1px solid var(--fs-border-color);
}
.dup-members {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
flex: 1 1 20rem;
min-width: 0;
}
.dup-member {
font-size: 0.8rem;
padding: 0.1rem 0.45rem;
border-radius: 4px;
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
/* Long snippet names must not push the row into a horizontal scroll. */
overflow-wrap: anywhere;
}
.dup-score {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.dup-action {
white-space: nowrap;
}
+2 -42
View File
@@ -803,7 +803,8 @@ useEditorGuards(dirty, save);
max-width: 1600px;
}
/* Replace .editor-body for task editor */
/* The task editor's own body row. It began as a replacement for the shared
.editor-body, which nothing used afterwards and has since been deleted. */
.task-body {
flex: 1;
min-height: 0;
@@ -823,16 +824,6 @@ useEditorGuards(dirty, save);
gap: 0.75rem;
}
.body-tabs-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
}
/* .task-main is a flex column; without flex-shrink: 0, long body content
gets squeezed back to min-height and overflows visibly on top of siblings. */
.body-editor-wrap,
@@ -840,10 +831,6 @@ useEditorGuards(dirty, save);
flex-shrink: 0;
}
.body-editor-wrap {
min-height: 200px;
}
:deep(.preview-pane) {
flex-shrink: 0;
}
@@ -949,18 +936,6 @@ useEditorGuards(dirty, save);
font-family: inherit;
}
.subtask-input:focus { outline: none; border-color: var(--fs-accent); }
.stream-preview {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.75rem;
background: var(--fs-surface-raised);
min-height: 200px;
}
.main-diff {
flex: 1;
min-height: 0;
}
/* Systems multi-select (in sidebar) */
.sb-systems { display: flex; flex-direction: column; gap: 0.25rem; max-height: 160px; overflow-y: auto; }
.sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--fs-text-primary); cursor: pointer; }
@@ -973,26 +948,11 @@ useEditorGuards(dirty, save);
flex-direction: column;
gap: 0.5rem;
}
.assist-section-title {
font-size: 0.78rem;
font-weight: 500;
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.assist-actions {
display: flex;
gap: 0.4rem;
}
/* Tag suggest row inside sidebar */
.tag-suggest-row {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
}
/* Lifecycle timestamps */
.sb-timestamps {
display: flex;
-767
View File
@@ -1,767 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTasksStore } from "@/stores/tasks";
import { useNotesStore } from "@/stores/notes";
import { renderMarkdown } from "@/utils/markdown";
import { relativeTime } from "@/composables/useRelativeTime";
import { apiPost, apiGet } from "@/api/client";
import type { Note } from "@/types/note";
import type { TaskStatus } from "@/types/task";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import TagPill from "@/components/TagPill.vue";
import TableOfContents from "@/components/TableOfContents.vue";
import ShareDialog from "@/components/ShareDialog.vue";
import { Clock, Pencil, Link as LinkIcon } from "lucide-vue-next";
const route = useRoute();
const router = useRouter();
const store = useTasksStore();
const notesStore = useNotesStore();
const backlinks = ref<{ type: string; id: number; title: string }[]>([]);
const converting = ref(false);
const showShare = ref(false);
// Context enrichment
const projectTitle = ref<string | null>(null);
const milestoneName = ref<string | null>(null);
const subTasks = ref<Note[]>([]);
const taskId = computed(() => Number(route.params.id));
const statusCycle: Record<TaskStatus, TaskStatus> = {
todo: "in_progress",
in_progress: "done",
done: "todo",
cancelled: "todo",
};
const statusDotClass: Record<TaskStatus, string> = {
todo: "dot-todo",
in_progress: "dot-in-progress",
done: "dot-done",
cancelled: "dot-cancelled",
};
function cycleSubTaskStatus(subTask: Note) {
if (!subTask.status) return;
const next = statusCycle[subTask.status as TaskStatus];
store.patchStatus(subTask.id, next).then(() => {
const idx = subTasks.value.findIndex((t) => t.id === subTask.id);
if (idx !== -1) subTasks.value[idx] = { ...subTasks.value[idx], status: next };
});
}
async function loadContext(task: Note) {
projectTitle.value = null;
milestoneName.value = null;
subTasks.value = [];
const promises: Promise<void>[] = [];
if (task.project_id) {
promises.push(
apiGet<any>(`/api/projects/${task.project_id}`).then((data) => {
projectTitle.value = data.title ?? null;
if (task.milestone_id && data.summary?.milestone_summary) {
const ms = (data.summary.milestone_summary as Array<{ id: number; title: string }>)
.find((m) => m.id === task.milestone_id);
if (ms) milestoneName.value = ms.title;
}
}).catch(() => {})
);
}
// Load sub-tasks via the notes endpoint with parent_id filter
promises.push(
apiGet<{ notes: Note[]; total: number }>(
`/api/notes?parent_id=${task.id}&type=task&sort=created_at&order=asc&limit=50`
).then((data) => {
subTasks.value = data.notes;
}).catch(() => {})
);
await Promise.all(promises);
}
async function loadTask(id: number) {
backlinks.value = [];
await store.fetchTask(id);
if (!store.currentTask) return;
const [bl] = await Promise.allSettled([
notesStore.fetchBacklinks(id),
loadContext(store.currentTask),
]);
if (bl.status === "fulfilled") backlinks.value = bl.value;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key !== "Escape") return;
e.stopPropagation(); // prevent App.vue's global handler from also firing
const active = document.activeElement as HTMLElement | null;
if (active && active !== document.body) {
(active as HTMLElement).blur();
return;
}
if (store.currentTask?.project_id) {
router.push(`/projects/${store.currentTask.project_id}`);
} else {
router.push("/tasks");
}
}
onMounted(() => {
loadTask(taskId.value);
// Capture phase so this fires before App.vue's document-level handler
window.addEventListener("keydown", handleKeydown, true);
});
onUnmounted(() => window.removeEventListener("keydown", handleKeydown, true));
watch(() => route.params.id, (newId) => {
if (newId) loadTask(Number(newId));
});
const renderedBody = computed(() => {
if (!store.currentTask) return "";
return renderMarkdown(store.currentTask.body);
});
function cycleStatus() {
if (!store.currentTask) return;
store.patchStatus(
store.currentTask.id,
statusCycle[store.currentTask.status as TaskStatus]
);
}
const forwardStatus: Record<TaskStatus, TaskStatus | null> = {
todo: "in_progress",
in_progress: "done",
done: null,
cancelled: null,
};
function recurrenceSummary(rule: Record<string, unknown> | null): string | null {
if (!rule) return null;
if (rule.type === "interval") {
return `Every ${rule.every} ${rule.unit}(s)`;
}
if (rule.type === "calendar") {
if (rule.unit === "month") return `Monthly on day ${rule.day_of_month}`;
if (rule.unit === "year") {
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const m = months[((rule.month as number) ?? 1) - 1];
return `Yearly on ${m} ${rule.day_of_month}`;
}
}
return null;
}
const advanceLabel = computed(() => {
const s = store.currentTask?.status as TaskStatus | undefined;
if (!s) return null;
const next = forwardStatus[s];
if (!next) return null;
return next === "in_progress" ? "→ In Progress" : "→ Done";
});
function advanceStatus() {
if (!store.currentTask) return;
const next = forwardStatus[store.currentTask.status as TaskStatus];
if (next) store.patchStatus(store.currentTask.id, next);
}
function isOverdue(): boolean {
if (!store.currentTask?.due_date || store.currentTask.status === "done")
return false;
const today = new Date().toISOString().slice(0, 10);
return store.currentTask.due_date < today;
}
async function convertToNote() {
if (converting.value) return;
converting.value = true;
try {
await notesStore.convertToNote(taskId.value);
const { useToastStore } = await import("@/stores/toast");
useToastStore().show("Converted to note");
router.push(`/notes/${taskId.value}`);
} catch {
const { useToastStore } = await import("@/stores/toast");
useToastStore().show("Failed to convert task", "error");
} finally {
converting.value = false;
}
}
async function onBodyClick(e: MouseEvent) {
const target = e.target as HTMLElement;
const tagLink = target.closest(".inline-tag") as HTMLAnchorElement | null;
if (tagLink) {
e.preventDefault();
const tag = tagLink.dataset.tag;
if (tag) {
router.push({ path: "/notes", query: { tag } });
}
return;
}
const wikilink = target.closest(".wikilink") as HTMLAnchorElement | null;
if (wikilink) {
e.preventDefault();
const title = wikilink.dataset.title;
if (title) {
try {
const note = await apiPost<Note>(
"/api/notes/resolve-title",
{ title }
);
router.push(`/notes/${note.id}`);
} catch {
const { useToastStore } = await import("@/stores/toast");
useToastStore().show(`Failed to resolve note "${title}"`, "error");
}
}
}
}
function onTagClick(tag: string) {
router.push({ path: "/tasks", query: { tag } });
}
// Sub-task progress
const subTaskProgress = computed(() => {
if (!subTasks.value.length) return null;
const done = subTasks.value.filter((t) => t.status === "done").length;
const total = subTasks.value.length;
return { done, total, pct: Math.round((done / total) * 100) };
});
</script>
<template>
<div class="viewer-layout">
<main class="viewer">
<div v-if="store.loading" class="viewer-skeleton" aria-label="Loading task">
<div class="skel-toolbar">
<div class="skel-btn"></div>
<div class="skel-btn skel-btn--wide"></div>
<div class="skel-btn"></div>
</div>
<div class="skel-title"></div>
<div class="skel-meta"></div>
<div class="skel-badges"></div>
<div class="skel-line"></div>
<div class="skel-line skel-line--short"></div>
<div class="skel-line"></div>
<div class="skel-line skel-line--medium"></div>
<div class="skel-line skel-line--short"></div>
</div>
<template v-else-if="store.currentTask">
<div class="toolbar">
<router-link
:to="store.currentTask.project_id ? `/projects/${store.currentTask.project_id}` : '/tasks'"
class="btn-ghost"
>{{ store.currentTask.project_id ? "← Project" : "← Tasks" }}</router-link>
<router-link
:to="`/tasks/${store.currentTask.id}/edit`"
class="btn-primary"
>
Edit
</router-link>
<button
v-if="advanceLabel"
class="btn-primary"
@click="advanceStatus"
>
{{ advanceLabel }}
</button>
<button
class="btn-secondary btn-compact"
@click="convertToNote"
:disabled="converting"
>
{{ converting ? "Converting..." : "Convert to Note" }}
</button>
<button class="btn-secondary btn-compact" @click="showShare = true">Share</button>
</div>
<!-- Breadcrumb: parent task project milestone -->
<div
v-if="store.currentTask.parent_id || store.currentTask.project_id"
class="context-bar"
>
<router-link
v-if="store.currentTask.parent_id"
:to="`/tasks/${store.currentTask.parent_id}`"
class="ctx-crumb ctx-crumb-parent"
>
{{ store.currentTask.parent_title || "Parent task" }}
</router-link>
<router-link
v-if="store.currentTask.project_id && projectTitle"
:to="`/projects/${store.currentTask.project_id}`"
class="ctx-crumb ctx-crumb-project"
>
{{ projectTitle }}
</router-link>
<span v-if="milestoneName" class="ctx-crumb ctx-crumb-milestone">
{{ milestoneName }}
</span>
</div>
<h1 class="task-title">{{ store.currentTask.title || "Untitled" }}</h1>
<p class="meta">
<span class="meta-item">
<Clock :size="16" />
Updated {{ relativeTime(store.currentTask.updated_at) }}
</span>
<span class="meta-sep" aria-hidden="true">·</span>
<span class="meta-item">
<Pencil :size="16" />
Created {{ relativeTime(store.currentTask.created_at) }}
</span>
</p>
<div class="badges">
<StatusBadge
:status="store.currentTask.status!"
clickable
@click="cycleStatus"
/>
<PriorityBadge :priority="store.currentTask.priority!" />
<span
v-if="store.currentTask.due_date"
:class="['due-date', { overdue: isOverdue() }]"
>
Due: {{ store.currentTask.due_date }}
</span>
</div>
<div class="task-meta-row" v-if="store.currentTask.started_at || store.currentTask.completed_at || store.currentTask.recurrence_rule">
<span v-if="store.currentTask.started_at" class="task-meta-item">
Started: {{ new Date(store.currentTask.started_at).toLocaleString() }}
</span>
<span v-if="store.currentTask.completed_at" class="task-meta-item">
Completed: {{ new Date(store.currentTask.completed_at).toLocaleString() }}
</span>
<span v-if="recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null)" class="task-meta-item task-meta-recurrence">
{{ recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null) }}
</span>
</div>
<div class="tags" v-if="store.currentTask.tags.length">
<TagPill
v-for="tag in store.currentTask.tags"
:key="tag"
:tag="tag"
@click="onTagClick"
/>
</div>
<div
v-if="store.currentTask.description"
class="task-goal-display"
>
<h3 class="goal-label">Goal</h3>
<p class="goal-text">{{ store.currentTask.description }}</p>
</div>
<div
class="body prose"
v-html="renderedBody"
@click="onBodyClick"
></div>
<!-- Sub-tasks -->
<div v-if="subTasks.length" class="subtasks">
<div class="subtasks-header">
<h2 class="subtasks-title">Sub-tasks</h2>
<span v-if="subTaskProgress" class="subtasks-progress">
{{ subTaskProgress.done }}/{{ subTaskProgress.total }}
<span class="subtasks-pct">({{ subTaskProgress.pct }}%)</span>
</span>
</div>
<div v-if="subTaskProgress" class="subtasks-track">
<div class="subtasks-fill" :style="{ width: subTaskProgress.pct + '%' }"></div>
</div>
<ul class="subtasks-list">
<li
v-for="sub in subTasks"
:key="sub.id"
class="subtask-row"
>
<button
:class="['sub-dot', statusDotClass[sub.status as TaskStatus] ?? 'dot-todo']"
:title="`${sub.status} — click to advance`"
@click="cycleSubTaskStatus(sub)"
></button>
<router-link :to="`/tasks/${sub.id}/edit`" class="sub-title" :class="{ 'sub-done': sub.status === 'done' }">
{{ sub.title || "Untitled" }}
</router-link>
<span v-if="sub.due_date" class="sub-due">{{ sub.due_date }}</span>
</li>
</ul>
</div>
<div v-if="backlinks.length" class="backlinks">
<h3 class="backlinks-heading">
<LinkIcon :size="16" />
Backlinks
<span class="backlinks-count">{{ backlinks.length }}</span>
</h3>
<div class="backlinks-grid">
<router-link
v-for="link in backlinks"
:key="`${link.type}-${link.id}`"
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
class="backlink-card"
>
<span :class="['backlink-type-badge', `badge-${link.type}`]">{{ link.type }}</span>
<span class="backlink-title">{{ link.title || "Untitled" }}</span>
</router-link>
</div>
</div>
</template>
<p v-else>Task not found.</p>
</main>
<TableOfContents
v-if="store.currentTask?.body"
:body="store.currentTask.body"
class="toc-sidebar"
/>
</div>
<ShareDialog
v-if="showShare && store.currentTask"
resource-type="note"
:resource-id="store.currentTask.id"
:resource-title="store.currentTask.title || '(untitled)'"
@close="showShare = false"
/>
</template>
<style src="@/assets/viewer-shared.css" />
<style scoped>
.viewer-layout {
display: flex;
max-width: 1400px;
margin: 0 auto;
gap: 2rem;
}
.viewer {
flex: 1;
min-width: 0;
max-width: 1100px;
margin: 2rem 0;
padding: 0 1rem;
}
.toc-sidebar {
margin-top: 2rem;
}
@media (max-width: 1200px) {
.toc-sidebar {
display: none;
}
}
.toolbar {
display: flex;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.83rem;
color: var(--fs-text-tertiary);
margin: 0 0 0.75rem;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.meta-sep {
opacity: 0.5;
}
.badges {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.due-date {
font-size: 0.85rem;
color: var(--fs-text-secondary);
}
.due-date.overdue {
color: var(--fs-overdue);
font-weight: 500;
}
.task-meta-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.task-meta-item {
font-size: 0.78rem;
color: var(--fs-text-tertiary);
}
.task-meta-recurrence {
color: var(--fs-accent);
font-weight: 500;
}
.tags {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
/* Sub-tasks */
.subtasks {
margin-top: 2rem;
border-top: 1px solid var(--fs-border-color);
padding-top: 1rem;
}
.subtasks-header {
display: flex;
align-items: baseline;
gap: 0.6rem;
margin-bottom: 0.4rem;
}
.subtasks-title {
font-size: 1rem;
margin: 0;
font-weight: 500;
}
.subtasks-progress {
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.subtasks-pct {
color: var(--fs-text-tertiary);
}
.subtasks-track {
height: 4px;
background: var(--fs-surface-raised);
border-radius: 2px;
margin-bottom: 0.75rem;
overflow: hidden;
}
.subtasks-fill {
height: 100%;
background: var(--fs-status-done);
border-radius: 2px;
transition: width 0.3s ease;
}
.subtasks-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.subtask-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.5rem;
border-radius: var(--fs-radius-sm);
}
.subtask-row:hover {
background: var(--fs-surface-raised);
}
.sub-dot {
flex-shrink: 0;
width: 12px;
height: 12px;
border-radius: 50%;
border: none;
cursor: pointer;
padding: 0;
transition: transform 0.1s, opacity 0.1s;
}
.sub-dot:hover {
transform: scale(1.25);
opacity: 0.8;
}
.dot-todo {
background: transparent;
border: 2px solid var(--fs-text-tertiary);
}
.dot-in-progress {
background: var(--fs-status-in-progress);
}
.dot-done {
background: var(--fs-status-done);
}
.dot-cancelled {
background: var(--fs-text-tertiary);
}
.sub-title {
flex: 1;
font-size: 0.9rem;
color: var(--fs-text-primary);
text-decoration: none;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub-title:hover {
color: var(--fs-accent);
}
.sub-title.sub-done {
color: var(--fs-text-tertiary);
text-decoration: line-through;
}
.sub-due {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
flex-shrink: 0;
}
.backlinks {
margin-top: 2.5rem;
border-top: 1px solid var(--fs-border-color);
padding-top: 1.25rem;
}
.backlinks-heading {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fs-text-tertiary);
margin: 0 0 0.75rem;
}
.backlinks-count {
margin-left: 0.2rem;
font-size: 0.72rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: 999px;
padding: 0 0.4rem;
line-height: 1.4;
}
.backlinks-grid {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.backlink-card {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.5rem 0.75rem;
border-radius: var(--fs-radius-lg);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
text-decoration: none;
color: var(--fs-text-primary);
transition: border-color 0.15s, box-shadow 0.15s;
font-size: 0.9rem;
}
.backlink-card:hover {
border-color: color-mix(in srgb, var(--fs-accent) 50%, transparent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
color: var(--fs-accent);
}
.backlink-type-badge {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 500;
padding: 0.1rem 0.45rem;
border-radius: 999px;
flex-shrink: 0;
}
.badge-note {
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
color: var(--fs-accent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
}
.badge-task {
background: color-mix(in srgb, #f59e0b 12%, transparent);
color: #d97706;
border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent);
}
.backlink-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Skeleton loader ── */
@keyframes skel-shine {
to { background-position: 200% center; }
}
.viewer-skeleton {
display: flex;
flex-direction: column;
gap: 0.65rem;
padding-top: 0.5rem;
}
.skel-btn,
.skel-title,
.skel-meta,
.skel-badges,
.skel-line {
border-radius: var(--fs-radius-sm);
background: linear-gradient(
90deg,
var(--fs-surface-raised) 25%,
color-mix(in srgb, var(--fs-text-tertiary) 18%, var(--fs-surface-raised)) 50%,
var(--fs-surface-raised) 75%
);
background-size: 200% 100%;
animation: skel-shine 1.5s ease infinite;
}
.skel-toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.skel-btn { width: 70px; height: 32px; }
.skel-btn--wide { width: 90px; }
.skel-title { height: 2.2rem; width: 65%; border-radius: var(--fs-radius-lg); }
.skel-meta { height: 0.85rem; width: 45%; }
.skel-badges { height: 1.6rem; width: 30%; border-radius: 999px; }
.skel-line { height: 0.9rem; }
.skel-line--short { width: 50%; }
.skel-line--medium { width: 78%; }
/* ── Goal block + auto-summary banner ─────────────────────────────────────── */
.task-goal-display {
border-left: 2px solid var(--fs-border-color);
padding: 0.4rem 0 0.4rem 0.9rem;
margin: 0.75rem 0 1.25rem;
background: rgba(255, 255, 255, 0.02);
}
.goal-label {
font-family: var(--fs-font-display);
font-style: italic;
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--fs-text-tertiary);
margin: 0 0 0.25rem;
}
.goal-text {
margin: 0;
font-size: 0.95rem;
line-height: 1.45;
color: var(--fs-text-primary);
white-space: pre-wrap;
}
</style>
-441
View File
@@ -1,441 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiGet, apiPost, apiPut, apiDelete, apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import type { User } from "@/types/auth";
import { fmtDate } from "@/utils/dateFormat";
interface Invitation {
id: number;
email: string;
created_at: string;
expires_at: string;
}
const authStore = useAuthStore();
const toastStore = useToastStore();
const users = ref<User[]>([]);
const registrationOpen = ref(false);
const loading = ref(true);
const toggling = ref(false);
const confirmDeleteId = ref<number | null>(null);
const deleting = ref<number | null>(null);
const inviteEmail = ref("");
const sendingInvite = ref(false);
const invitations = ref<Invitation[]>([]);
const revokingId = ref<number | null>(null);
onMounted(async () => {
await Promise.all([fetchUsers(), fetchRegistration(), fetchInvitations()]);
loading.value = false;
});
async function fetchUsers() {
try {
const data = await apiGet<{ users: User[] }>("/api/admin/users");
users.value = data.users;
} catch {
toastStore.show("Failed to load users", "error");
}
}
async function fetchRegistration() {
try {
const data = await apiGet<{ open: boolean }>("/api/admin/registration");
registrationOpen.value = data.open;
} catch {
// Ignore — will default to false
}
}
async function fetchInvitations() {
try {
const data = await apiGet<{ invitations: Invitation[] }>("/api/admin/invitations");
invitations.value = data.invitations;
} catch {
// Ignore
}
}
async function sendInvite() {
const email = inviteEmail.value.trim().toLowerCase();
if (!email) return;
sendingInvite.value = true;
try {
await apiPost("/api/admin/invitations", { email });
toastStore.show(`Invitation sent to ${email}`);
inviteEmail.value = "";
await fetchInvitations();
} catch (e: unknown) {
toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
} finally {
sendingInvite.value = false;
}
}
async function revokeInvitation(id: number) {
revokingId.value = id;
try {
await apiDelete(`/api/admin/invitations/${id}`);
invitations.value = invitations.value.filter((inv) => inv.id !== id);
toastStore.show("Invitation revoked");
} catch {
toastStore.show("Failed to revoke invitation", "error");
} finally {
revokingId.value = null;
}
}
async function toggleRegistration() {
toggling.value = true;
try {
const data = await apiPut<{ open: boolean }>("/api/admin/registration", {
open: !registrationOpen.value,
});
registrationOpen.value = data.open;
toastStore.show(data.open ? "Registration opened" : "Registration closed");
} catch {
toastStore.show("Failed to update registration setting", "error");
} finally {
toggling.value = false;
}
}
function confirmDelete(userId: number) {
if (confirmDeleteId.value === userId) {
deleteUser(userId);
} else {
confirmDeleteId.value = userId;
}
}
function cancelDelete() {
confirmDeleteId.value = null;
}
async function deleteUser(userId: number) {
confirmDeleteId.value = null;
deleting.value = userId;
try {
await apiDelete(`/api/admin/users/${userId}`);
users.value = users.value.filter((u) => u.id !== userId);
toastStore.show("User deleted");
} catch (e: unknown) {
toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
} finally {
deleting.value = null;
}
}
</script>
<template>
<main class="users-page">
<h1>User Management</h1>
<section class="settings-section">
<h2>Registration</h2>
<div class="registration-row">
<div class="registration-info">
<p class="registration-status">
Registration is currently
<strong :class="registrationOpen ? 'text-success' : 'text-muted'">
{{ registrationOpen ? "open" : "closed" }}
</strong>
</p>
<p class="field-hint">
When closed, new users can only be added by an administrator.
</p>
</div>
<button
class="btn-primary btn-toggle"
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
@click="toggleRegistration"
:disabled="toggling"
>
{{ toggling ? "Updating..." : registrationOpen ? "Close Registration" : "Open Registration" }}
</button>
</div>
</section>
<section class="settings-section">
<h2>Invite User</h2>
<form class="invite-form" @submit.prevent="sendInvite">
<input
v-model="inviteEmail"
type="email"
placeholder="Email address"
class="input invite-input"
required
:disabled="sendingInvite"
/>
<button
type="submit"
class="btn-primary"
:disabled="sendingInvite || !inviteEmail.trim()"
>
{{ sendingInvite ? "Sending..." : "Send Invite" }}
</button>
</form>
<p class="field-hint">Send an invitation link to allow someone to register, even when public registration is closed.</p>
<div v-if="invitations.length > 0" class="invite-list">
<h3>Pending Invitations</h3>
<table class="users-table">
<thead>
<tr>
<th>Email</th>
<th class="hide-mobile">Sent</th>
<th class="hide-mobile">Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="inv in invitations" :key="inv.id">
<td class="cell-email">{{ inv.email }}</td>
<td class="hide-mobile cell-date">{{ fmtDate(inv.created_at) }}</td>
<td class="hide-mobile cell-date">{{ fmtDate(inv.expires_at) }}</td>
<td class="cell-actions">
<button
class="btn-ghost btn-compact"
@click="revokeInvitation(inv.id)"
:disabled="revokingId !== null"
>
{{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="settings-section">
<h2>Users</h2>
<div v-if="loading" class="loading-msg">Loading users...</div>
<div v-else-if="users.length === 0" class="empty-msg">No users found.</div>
<table v-else class="users-table">
<thead>
<tr>
<th>Username</th>
<th class="hide-mobile">Email</th>
<th>Role</th>
<th class="hide-mobile">Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td class="cell-username">{{ u.username }}</td>
<td class="hide-mobile cell-email">{{ u.email || "—" }}</td>
<td>
<span class="role-badge" :class="u.role === 'admin' ? 'role-admin' : 'role-user'">
{{ u.role }}
</span>
</td>
<td class="hide-mobile cell-date">{{ fmtDate(u.created_at) }}</td>
<td class="cell-actions">
<template v-if="u.id === authStore.user?.id">
<span class="you-label">You</span>
</template>
<template v-else-if="confirmDeleteId === u.id">
<button
class="btn-danger btn-compact"
@click="confirmDelete(u.id)"
:disabled="deleting !== null"
>
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
</button>
<button class="btn-ghost btn-compact" @click="cancelDelete">Cancel</button>
</template>
<template v-else>
<button
class="btn-ghost btn-compact"
@click="confirmDelete(u.id)"
:disabled="deleting !== null"
>
Delete
</button>
</template>
</td>
</tr>
</tbody>
</table>
</section>
</main>
</template>
<style scoped>
.users-page {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
}
.users-page h1 {
margin: 0 0 1.5rem;
}
.settings-section {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.settings-section h2 {
margin: 0 0 0.75rem;
font-size: 1.1rem;
}
/* Invite form */
.invite-form {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.invite-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.invite-input:focus {
outline: none;
border-color: var(--fs-accent);
}
.invite-list {
margin-top: 1rem;
}
.invite-list h3 {
margin: 0 0 0.5rem;
font-size: 0.95rem;
color: var(--fs-text-secondary);
}
/* Registration toggle */
.registration-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.registration-info {
flex: 1;
}
.registration-status {
margin: 0;
font-size: 0.95rem;
}
.text-success {
color: var(--fs-success);
}
.text-muted {
color: var(--fs-text-tertiary);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
/* The one genuine override: 'close registration' must NOT read as the
primary action it sits on. Scoped, so it beats the shared variant. */
.btn-toggle-close {
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color);
}
.btn-toggle-close:hover:not(:disabled) {
border-color: var(--fs-warning);
color: var(--fs-warning);
}
/* Users table */
.loading-msg,
.empty-msg {
text-align: center;
color: var(--fs-text-tertiary);
font-size: 0.9rem;
padding: 1rem 0;
}
.users-table {
width: 100%;
border-collapse: collapse;
}
.users-table th {
text-align: left;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-text-tertiary);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
}
.users-table td {
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
font-size: 0.9rem;
}
.users-table tbody tr:last-child td {
border-bottom: none;
}
.cell-username {
font-weight: 600;
}
.cell-email {
color: var(--fs-text-secondary);
}
.cell-date {
color: var(--fs-text-tertiary);
font-size: 0.85rem;
}
.cell-actions {
white-space: nowrap;
}
/* Role badges */
.role-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.4rem;
border-radius: var(--fs-radius-sm);
}
.role-admin {
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
}
.role-user {
color: var(--fs-text-tertiary);
background: var(--fs-surface-raised);
}
/* Action buttons */
.you-label {
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
@media (max-width: 768px) {
.registration-row {
flex-direction: column;
align-items: flex-start;
}
.btn-toggle {
width: 100%;
}
.invite-form {
flex-direction: column;
}
}
</style>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.37",
"version": "0.1.46",
"author": { "name": "Bryan Van Deusen" },
"mcpServers": {
"scribe": {
+18 -2
View File
@@ -52,8 +52,24 @@ On install you'll be asked for:
but never stop it; silent when nothing is recorded, which is most of the time.
Two framings: a REUSE menu (similar/nearby records), and a SYNC nudge when a
snippet records the exact file being edited — "updating the record is part of
the edit" — each with its own once-per-session dedup.
Toggle in **Settings → Knowledge auto-inject**.
the edit" — each with its own once-per-session dedup. A third, ledger-fed
line names a duplicate family (no canon) or a canon recorded elsewhere for
the names being written (its own dedup channel, `exclude_derive`).
Fail-open but not fail-silent: a configured instance that does not answer
in time is said, once per outage ("Scribe did not answer … this write went
UNCHECKED"), so a session can tell "checked, nothing there" from "never
checked"; an answer clears the marker. The local by-name arm needs no
server and always runs. Toggle in **Settings → Knowledge auto-inject**.
- `hooks/hooks.json` → PostToolUse hook on `Bash`
(`hooks/scribe_after_write.sh`): code written through sed/heredocs/scripts
never reaches the PreToolUse hook, so this one diffs the working tree after
every Bash call (per-session path+blob snapshot; one `git status` when
nothing changed) and runs the same arms on the definitions just written,
through the same endpoint and the same dedup channels. `additionalContext`
only; never blocks, and shares the pre-write hook's once-per-outage "did not
answer" line (8 s budget here — it runs after the tool, so it gates
nothing). The extractor, the prose/data skip list, the local by-name
duplicate arm and the outage line are shared in `hooks/scribe_defs.sh`.
- `skills/` → the universal process-skills, surfaced by description match.
- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync`
command → generate `~/.claude/skills/scribe-proc-*` stubs from your Scribe
+11
View File
@@ -34,6 +34,17 @@
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_after_write.sh\""
}
]
}
]
}
}
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env bash
# Scribe plugin — PostToolUse write-path trigger on Bash (#2901).
#
# scribe_prior_art.sh fires before a Write/Edit TOOL CALL. Code written any
# other way — sed, heredocs, python edit scripts, `cat > file` — never reached
# it, so a whole class of edits (the ones a long session makes most) got no
# prior-art hint, no ledger feed and no duplicate-family warning. This hook
# closes that: after EVERY Bash call it asks git what changed in the working
# tree since it last looked, and runs the same arms on the definitions that
# were just written — the local by-name duplicate arm, the recorded prior-art
# arms and the ledger's derive/divergence checks (#2900/#2793), via the same
# /api/plugin/prior-art endpoint the pre-write hook uses.
#
# Post-hoc by a few seconds, in the same moment and the same session: "the
# copy just landed; here is its family" — not "an audit found it later".
#
# Cheap when nothing changed: one `git status`. State per session, beside the
# pre-write hook's (its three dedup channels are SHARED, so a family named by
# one hook is not named again by the other):
# ${TMPDIR:-/tmp}/scribe-afterwrite/<sid>.snap path<TAB>blob-hash of every
# dirty/untracked file last seen
# ${TMPDIR:-/tmp}/scribe-priorart/<sid>.* the dedup channels
#
# NEVER BLOCKS. It returns `additionalContext` only (no decision — there is
# nothing left to decide, the write already happened). Any failure —
# unconfigured, unreachable, not a git repo, malformed — exits 0 in silence.
#
# Config (same as the other hooks):
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
set -uo pipefail
command -v jq >/dev/null 2>&1 || exit 0
command -v git >/dev/null 2>&1 || exit 0
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# PostToolUse delivers { session_id, cwd, tool_name, tool_input, tool_response }.
event=$(cat 2>/dev/null || true)
tool_name=$(printf '%s' "$event" | jq -r '.tool_name // empty' 2>/dev/null) || exit 0
[ "$tool_name" = "Bash" ] || exit 0
session_id=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_cwd=""
work_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
repo_root=$(git -C "$work_dir" rev-parse --show-toplevel 2>/dev/null) || exit 0
[ -n "$repo_root" ] || exit 0
safe_sid=$(printf '%s' "${session_id:-nosession}" | tr -c 'A-Za-z0-9._-' '_')
snap_dir="${TMPDIR:-/tmp}/scribe-afterwrite"
mkdir -p "$snap_dir" 2>/dev/null || true
snap="$snap_dir/${safe_sid}.snap"
# What is dirty now: every modified / added / untracked path, with the blob
# hash of its working-tree content. Hash, not mtime: portable (no stat
# flags), exact (a touch is not a change), and untracked files hash the same
# way tracked ones do.
current=""
while IFS= read -r line; do
[ -n "$line" ] || continue
status=${line:0:2}
path=${line:3}
case "$status" in
D*|*D) continue ;; # a deletion defines nothing
esac
case "$path" in
*" -> "*) path=${path##* -> } ;; # rename: the new name
esac
# Porcelain quotes paths with special characters; those are skipped rather
# than unquoted badly — a filename needing quotes is not where shapes live.
case "$path" in
\"*) continue ;;
esac
[ -f "$repo_root/$path" ] || continue
sha=$(git -C "$repo_root" hash-object -- "$path" 2>/dev/null) || continue
current="${current}${path}"$'\t'"${sha}"$'\n'
done < <(git -C "$repo_root" status --porcelain --untracked-files=all 2>/dev/null)
previous=""
[ -f "$snap" ] && previous=$(cat "$snap" 2>/dev/null || true)
first_run=0
[ -f "$snap" ] || first_run=1
# Write the new snapshot NOW, before anything can fail below — the next call
# must compare against this tree, whatever happens to this one's hint.
printf '%s' "$current" > "$snap" 2>/dev/null || true
# Changed = a (path, hash) pair not in the previous snapshot. On the very
# first call of a session there is no previous snapshot; rather than report
# every pre-existing dirty file as "just written", take only files touched in
# the last minute — the Bash call that just ran is the likely author.
changed=""
while IFS=$'\t' read -r path sha; do
[ -n "${path:-}" ] || continue
if [ "$first_run" = 1 ]; then
[ -n "$(find "$repo_root/$path" -mmin -1 2>/dev/null)" ] || continue
else
case "$previous" in
*"${path}"$'\t'"${sha}"*) continue ;;
esac
fi
scribe_skip_path "$path" && continue
changed="${changed}${path}"$'\n'
done <<< "$current"
[ -n "$changed" ] || exit 0
scribe_config || : # sets url/token; the call below is guarded on them
repo=$(git -C "$repo_root" remote get-url origin 2>/dev/null || true)
repo_q=""
if [ -n "$repo" ]; then
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
[ -n "$enc" ] && repo_q="&repo=${enc}"
fi
# The dedup channels are the PRE-write hook's files, on purpose (see header).
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
mkdir -p "$state_dir" 2>/dev/null || true
idfile="$state_dir/${safe_sid}.ids"
syncfile="$state_dir/${safe_sid}.sync.ids"
derivefile="$state_dir/${safe_sid}.derive.ids"
combined=""
n_files=0
while IFS= read -r rel_path; do
[ -n "${rel_path:-}" ] || continue
# A Bash call that rewrote many files is a refactor or a generator, not a
# shape being instantiated; four is enough to name what matters.
n_files=$((n_files + 1))
[ "$n_files" -le 4 ] || break
file_path="$repo_root/$rel_path"
# The code just written: the ADDED lines of the uncommitted diff for a
# tracked file (sed, not cut: this strips one marker char per line, it is
# not a payload cap), the whole file when untracked.
if git -C "$repo_root" ls-files --error-unmatch -- "$rel_path" >/dev/null 2>&1; then
code=$(git -C "$repo_root" diff -U0 -- "$rel_path" 2>/dev/null | grep '^+' | grep -v '^+++' | sed 's/^+//') || code=""
else
code=$(cat "$file_path" 2>/dev/null) || code=""
fi
[ -n "$code" ] || continue
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12) || names=""
# Nothing DEFINED in what was written (prose, data, a call-site edit) →
# nothing to say; the arms are about shapes.
[ -n "$names" ] || continue
local_lines=$(scribe_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
local_context=""
if [ -n "$local_lines" ]; then
local_context="> Already defined elsewhere in this repo — \`${rel_path}\` (just written) adds another copy; check before keeping it (\`git grep\` shown; a nudge, not a gate):"$'\n'"${local_lines}"
fi
context=""
body=""
reached="" # "" unconfigured (no call owed) · 1 answered · 0 did not
unreached_context=""
if [ -n "$url" ] && [ -n "$token" ]; then
q=$(printf '%s' "$code" | head -c 1200)
path_enc=$(printf '%s' "$rel_path" | jq -sRr '@uri' 2>/dev/null) || path_enc=""
code_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || code_enc=""
shapes_q=""
enc=$(printf '%s\n' "$names" \
| awk -F'\t' 'NF>=2 {printf "%s%s:%s", (n++?",":""), $1, $2}' \
| jq -sRr '@uri' 2>/dev/null) || enc=""
[ -n "$enc" ] && shapes_q="&shapes=${enc}"
exclude_q=""; sync_exclude_q=""; derive_exclude_q=""
if [ -f "$idfile" ]; then
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
fi
if [ -f "$syncfile" ]; then
sync_seen=$(tr '\n' ',' < "$syncfile" 2>/dev/null | sed 's/,$//')
[ -n "$sync_seen" ] && sync_exclude_q="&exclude_sync_ids=${sync_seen}"
fi
if [ -f "$derivefile" ]; then
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | jq -sRr '@uri' 2>/dev/null) || derive_seen=""
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
fi
if [ -n "$path_enc" ]; then
# 8s, not the pre-write hook's 5: this hook runs AFTER the tool, so it
# gates nothing the session is waiting on, and the first prior-art call
# after a redeploy is a cold start (embedding warm-up, ~4.6s observed)
# that a 4s cap turned into a silent fail-open — the one write a
# session most wants the ledger's word on lost it.
reached=1
body=$(curl -fsS --max-time 8 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${shapes_q}" 2>/dev/null) || { body=""; reached=0; }
# A call that was owed and didn't come back is said, once per outage
# (#2932) — shared marker with the pre-write hook, so one outage is one
# line however the code was written.
if [ "$reached" = 1 ]; then
scribe_reached "$state_dir" "$safe_sid"
else
unreached_context=$(scribe_unreached "$state_dir" "$safe_sid" 8 "$rel_path")
fi
fi
if [ -n "$body" ]; then
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context=""
if [ -n "$context" ]; then
printf '%s' "$body" | jq -r '((.note_ids // []) - (.sync_note_ids // []))[]?' 2>/dev/null >> "$idfile" || true
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
printf '%s' "$body" | jq -r '(.derive_keys // [])[]?' 2>/dev/null >> "$derivefile" || true
# Several files in one call may name the same family: keep each
# token once, so the next request's exclude list stays exact.
for f in "$idfile" "$syncfile" "$derivefile"; do
[ -s "$f" ] && { sort -u -o "$f" "$f" 2>/dev/null || true; }
done
fi
fi
fi
# The record nudge (#2664), same gate as the pre-write hook: duplication
# demonstrated locally AND nothing recorded for it — and (#2932) never on a
# call that did not answer; "nothing recorded" is a claim only an answer
# can back.
if [ -n "$local_lines" ] && [ "$reached" != 0 ]; then
n_recorded=$(printf '%s' "$body" | jq -r '.note_ids | length' 2>/dev/null) || n_recorded=0
if [ "${n_recorded:-0}" = "0" ] || [ "$n_recorded" = "" ]; then
local_context="${local_context}"$'\n'"> None of those existing copies is recorded in Scribe. If the version just written is the canonical one — or this edit is consolidating the copies — record it now with create_snippet so the next session is offered it instead of writing another copy."
fi
fi
part="$local_context"
if [ -n "$context" ]; then
[ -n "$part" ] && part="${part}"$'\n'
part="${part}${context}"
fi
if [ -n "$unreached_context" ]; then
[ -n "$part" ] && part="${part}"$'\n'
part="${part}${unreached_context}"
fi
[ -n "$part" ] || continue
[ -n "$combined" ] && combined="${combined}"$'\n'
combined="${combined}${part}"
done <<< "$changed"
[ -n "$combined" ] || exit 0
jq -n --arg c "$combined" \
'{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $c}}'
exit 0
+4 -6
View File
@@ -23,6 +23,9 @@
# note is injected at most once per session. Passed back as exclude_ids.
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
command -v jq >/dev/null 2>&1 || exit 0
command -v curl >/dev/null 2>&1 || exit 0
@@ -35,13 +38,8 @@ event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_c
# Nothing to retrieve against.
[ -n "$prompt" ] || exit 0
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
# Guard against an unexpanded ${...} placeholder arriving as a literal.
case "$url" in *'${'*) url="" ;; esac
case "$token" in *'${'*) token="" ;; esac
# Unconfigured install → silent (auto-inject is pure enrichment).
[ -n "$url" ] && [ -n "$token" ] || exit 0
scribe_config || exit 0
# Cap the query length — a giant prompt makes a giant URL for no extra signal.
# `head -c`, not `cut -c1-2000`: cut is line-oriented and caps EACH LINE, so a
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# shellcheck shell=bash
# Scribe plugin — the pieces the hooks share (#2901, #2278).
#
# scribe_prior_art.sh fires BEFORE a Write/Edit tool call; scribe_after_write.sh
# fires AFTER a Bash tool call and diffs the working tree, so code written by
# sed/heredocs/scripts gets the same prior-art and ledger checks. Both need the
# same three things, kept here so they cannot drift apart:
#
# scribe_skip_path PATH formats that hold prose or data, not shapes
# scribe_defs stdin code → "kind<TAB>name" per definition
# scribe_local_dups ROOT REL "kind<TAB>name" lines on stdin → the by-name
# local-duplicate lines (ARM 1, #2280)
# scribe_unreached STATE SID SECS REL the "Scribe didn't answer" line, once
# per outage (#2932) — or nothing, if said lately
# scribe_reached STATE SID the server answered: the next outage speaks again
# scribe_config sets `url` + `token` from the env, returns 0
# only if BOTH are usable (#2278)
#
# Sourced, not executed: `. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"`.
# Skip formats that hold prose or data rather than reusable code. Purely to
# avoid a pointless round-trip — the server would return nothing for these
# anyway. Config formats are NOT skipped: a CI workflow or a compose file is
# often exactly the thing worth reusing.
scribe_skip_path() {
case "$1" in
*.md|*.mdx|*.txt|*.rst|*.json|*.lock|*.log|*.csv|*.tsv|*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf)
return 0 ;;
esac
return 1
}
# ---------------------------------------------------------------------------
# kind<TAB>name for each thing a piece of code DEFINES, in source order. One
# program, two consumers: the local duplicate arm (every definition in the
# payload) and the ledger feed (#2791, below: the definitions being written,
# or the one enclosing an Edit). Rule-for-rule mirrored by the server's
# services/coverage.py extract_shapes — ledger rows are keyed by what THAT
# sees, so the two must agree on what counts as a definition.
scribe_defs() {
awk '
{
# CSS class definition: .name { or .name,
if (match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) {
t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t)
if (t != "") print "css\t" t; next
}
line = $0; sub(/^[[:space:]]+/, "", line)
# Strip leading declaration modifiers so the definition keyword is the
# first word regardless of language (export/pub/private/suspend/...).
sub(/^((pub(\([a-z]+\))?|export|default|private|internal|protected|public|static|suspend|async|open|sealed|data|abstract|final|inline|unsafe|extern|override)[[:space:]]+)*/, "", line)
# Go method with receiver: func (r *T) Name(
if (match(line, /^func[[:space:]]*\([^)]*\)[[:space:]]*[A-Za-z_]/)) {
t = line; sub(/^func[[:space:]]*\([^)]*\)[[:space:]]*/, "", t)
sub(/[^A-Za-z0-9_].*$/, "", t)
if (t != "") print "sym\t" t; next
}
# Keyword-announced definitions, functions and named types alike.
# Dunders are skipped: every class defines __init__, so "already defined
# in N other files" is guaranteed noise for them — and noise is what
# teaches sessions to skip the hint.
if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) {
t = line; sub(/^[a-z]+[[:space:]]+/, "", t)
sub(/[^A-Za-z0-9_$].*$/, "", t)
# `type` defines only when something follows the name (= or {); an
# import specifier `type Foo,` is the same two words and defines
# nothing (mirror of coverage.py, #2904).
if (line ~ /^type[[:space:]]/) {
rest = line; sub(/^type[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/, "", rest)
if (rest !~ /[={]/) next
}
if (t != "" && t !~ /^__.*__$/) print "sym\t" t; next
}
# Arrow/expression assignment: const name = (…) / let name = async (
if (match(line, /^(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/)) {
t = line; sub(/^(const|let)[[:space:]]+/, "", t)
sub(/[^A-Za-z0-9_$].*$/, "", t)
if (t != "") print "sym\t" t; next
}
}
' 2>/dev/null
}
# ---------------------------------------------------------------------------
# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist?
#
# The recorded arms ask Scribe what was RECORDED; the ledger arm (#2900) asks
# what a BOUND repo's ledger knows. A helper nobody recorded, in a repo nobody
# bound, is invisible to both — which is how `.btn-primary` came to be defined
# four times, already diverged. This arm asks the one question only the
# developer's machine can answer, inside the repo, holding the code about to
# be written: no index, no storage, no server — it runs even on an install
# that has never configured Scribe.
#
# Definition-shaped patterns only. Grepping for bare occurrences would match
# every CALL site and drown the real finding — and a hint that is mostly noise
# is one people learn to skip, which is worse than none. ALL code, not a
# language shortlist (#2682): the same keyword family scribe_defs announces.
#
# $1 repo root, $2 repo-relative path of the file being written (excluded from
# the grep — it would always match itself on an Edit). Definitions on stdin.
# Prints one "> - `name` is already defined in N other file(s): …" per hit.
scribe_local_dups() {
local root="$1" rel="$2" kind name pat hits count label files
while IFS=$'\t' read -r kind name; do
[ -n "${name:-}" ] || continue
case "$kind" in
css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;;
*) pat="(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+${name}[^A-Za-z0-9_]|func[[:space:]]*\([^)]*\)[[:space:]]*${name}[[:space:]]*\(|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
esac
# -I skips binaries; :(exclude) drops the file being written.
hits=$(git -C "$root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel}" 2>/dev/null | head -4) || hits=""
[ -n "$hits" ] || continue
count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0)
label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name")
files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//')
printf '> - `%s` is already defined in %s other file(s): %s\n' "$label" "$count" "$files"
done
}
# ---------------------------------------------------------------------------
# The blind spot made visible (#2932). Both write-path hooks fail OPEN when the
# instance is slow or down — right for noise, wrong for silence: a session
# cannot tell "the ledger checked and found nothing" from "the ledger never
# answered", and a self-surfacing system cannot afford an invisible miss (the
# first write after a redeploy lost its derive line to a 4s cold start and
# nobody knew). So a failed call says so — ONCE per outage: the marker holds
# the time it last spoke; within ten minutes of that it stays quiet, and a
# successful call clears it so the next outage announces itself afresh.
# Unconfigured installs never reach this: no URL/token means no call was owed.
# Where every hook gets its endpoint and credential. Four lines, and each of
# the five hooks carried its own copy until #2278 — which is exactly the
# missing-sibling shape: the `${...}` guard below is a correctness detail a
# sixth hook would have forgotten, and nothing would have failed loudly.
#
# Sets `url` and `token` as globals rather than echoing them: a token must not
# pass through a subshell's output, where it could land in a log or an `xtrace`
# line. Returns 0 only when both are usable, so a caller can either bail
# (`scribe_config || exit 0`) or carry on degraded — the session-context hook
# still owes its static floor when Scribe is unconfigured.
# Declared here, not just assigned inside the function: `scribe_defs.sh` owns
# these two names, and a sourcing hook should have them defined the moment it
# sources — before any code path that might reference them. It also lets
# the linter see the assignment, which it cannot follow into a function in
# another file without -x (SC2154).
url=""
token=""
scribe_config() {
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
# An unexpanded `${...}` placeholder arriving as a literal would be sent as a
# garbage Bearer token and 401. Treat it as unset.
case "$url" in *'${'*) url="" ;; esac
case "$token" in *'${'*) token="" ;; esac
[ -n "$url" ] && [ -n "$token" ]
}
_SCRIBE_UNREACHED_QUIET=600
scribe_unreached() {
local marker="$1/$2.unreached" now last
now=$(date +%s 2>/dev/null) || now=0
if [ -f "$marker" ]; then
last=$(cat "$marker" 2>/dev/null) || last=0
case "$last" in ''|*[!0-9]*) last=0 ;; esac
[ $((now - last)) -lt "$_SCRIBE_UNREACHED_QUIET" ] && return 0
fi
printf '%s' "$now" > "$marker" 2>/dev/null || true
printf '> Scribe did not answer the prior-art check for `%s` within %ss — this write went UNCHECKED against the record and the shape ledger (the local by-name arm, if it spoke above, needed no server). If the name matters, check it yourself: `search` for the concept, `list_shapes(project_id, path=…)` for the ledger. Said once per outage; if it keeps happening the instance is slow or down.' "$4" "$3"
}
scribe_reached() {
rm -f "$1/$2.unreached" 2>/dev/null || true
}
+46 -106
View File
@@ -51,14 +51,12 @@ code=$(printf '%s' "$event" | jq -r '
.tool_input.content // .tool_input.file_content //
.tool_input.new_string // .tool_input.new_str // empty' 2>/dev/null) || code=""
# Skip formats that hold prose or data rather than reusable code. Purely to
# avoid a pointless round-trip — the server would return nothing for these
# anyway. Config formats are NOT skipped: a CI workflow or a compose file is
# often exactly the thing worth reusing.
case "$file_path" in
*.md|*.mdx|*.txt|*.rst|*.json|*.lock|*.log|*.csv|*.tsv|*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf)
exit 0 ;;
esac
# Shared with the after-write hook (#2901): the prose/data skip list, the
# definition extractor and the local by-name duplicate arm live in
# scribe_defs.sh so the two hooks cannot drift apart.
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
scribe_skip_path "$file_path" && exit 0
# Snippet locations are recorded repo-relative, so send a repo-relative path —
# an absolute one would simply match nothing. Resolved BEFORE the config gate
@@ -73,78 +71,8 @@ if [ -n "$repo_root" ]; then
esac
fi
# ---------------------------------------------------------------------------
# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist?
#
# The other two arms ask Scribe what was RECORDED. Scribe has never read a line
# of the codebase, so a helper nobody thought to record is invisible to them —
# which is how `.btn-primary` came to be defined four times, in four scoped
# stylesheets, already diverged. It was never a snippet, so no threshold and no
# query rewrite could ever have surfaced it.
#
# This arm closes that by asking the only question the record cannot answer,
# in the only place that can: the hook already runs on the developer's machine,
# inside the repo, holding the code about to be written. No index, no storage,
# no staleness, and no server — it deliberately runs even on an install that
# has never configured Scribe.
#
# Definition-shaped patterns only. Grepping for bare occurrences would match
# every CALL site and drown the real finding — and a hint that is mostly noise
# is one people learn to skip, which is worse than none.
#
# ALL code, not a language shortlist (#2682): the detector was born covering
# only the languages of the repo it was written in, which silently amputated
# this whole arm — and the record nudge gated on it — for every Go/Kotlin/Rust
# project. Definitions are announced by a small keyword family across
# languages (func/fun/fn/function/def/sub · class/struct/trait/interface/
# enum/object/protocol/type), so one modifier-strip + keyword match covers
# them all. Known out of scope: keyword-less declaration syntax (C/Java/Dart
# `ReturnType name(...)`) needs a real parser, and `impl` blocks are excluded
# because several per type is normal Rust, not duplication.
# ---------------------------------------------------------------------------
# kind<TAB>name for each thing a piece of code DEFINES, in source order. One
# program, two consumers: the local duplicate arm (every definition in the
# payload) and the ledger feed (#2791, below: the definitions being written,
# or the one enclosing an Edit). Rule-for-rule mirrored by the server's
# services/coverage.py extract_shapes — ledger rows are keyed by what THAT
# sees, so the two must agree on what counts as a definition.
scribe_defs() {
awk '
{
# CSS class definition: .name { or .name,
if (match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) {
t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t)
if (t != "") print "css\t" t; next
}
line = $0; sub(/^[[:space:]]+/, "", line)
# Strip leading declaration modifiers so the definition keyword is the
# first word regardless of language (export/pub/private/suspend/...).
sub(/^((pub(\([a-z]+\))?|export|default|private|internal|protected|public|static|suspend|async|open|sealed|data|abstract|final|inline|unsafe|extern|override)[[:space:]]+)*/, "", line)
# Go method with receiver: func (r *T) Name(
if (match(line, /^func[[:space:]]*\([^)]*\)[[:space:]]*[A-Za-z_]/)) {
t = line; sub(/^func[[:space:]]*\([^)]*\)[[:space:]]*/, "", t)
sub(/[^A-Za-z0-9_].*$/, "", t)
if (t != "") print "sym\t" t; next
}
# Keyword-announced definitions, functions and named types alike.
# Dunders are skipped: every class defines __init__, so "already defined
# in N other files" is guaranteed noise for them — and noise is what
# teaches sessions to skip the hint.
if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) {
t = line; sub(/^[a-z]+[[:space:]]+/, "", t)
sub(/[^A-Za-z0-9_$].*$/, "", t)
if (t != "" && t !~ /^__.*__$/) print "sym\t" t; next
}
# Arrow/expression assignment: const name = (…) / let name = async (
if (match(line, /^(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/)) {
t = line; sub(/^(const|let)[[:space:]]+/, "", t)
sub(/[^A-Za-z0-9_$].*$/, "", t)
if (t != "") print "sym\t" t; next
}
}
' 2>/dev/null
}
# ARM 1 — BY NAME, LOCALLY (#2280): does a definition of this already exist
# in the repo? (scribe_local_dups in scribe_defs.sh carries the why.)
names=""
if [ -n "$code" ]; then
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12) || names=""
@@ -152,21 +80,8 @@ fi
local_lines=""
if [ -n "$repo_root" ] && [ -n "$names" ]; then
while IFS=$'\t' read -r kind name; do
[ -n "${name:-}" ] || continue
case "$kind" in
css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;;
*) pat="(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+${name}[^A-Za-z0-9_]|func[[:space:]]*\([^)]*\)[[:space:]]*${name}[[:space:]]*\(|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
esac
# -I skips binaries; :(exclude) drops the file being written, which would
# otherwise always match itself on an Edit.
hits=$(git -C "$repo_root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel_path}" 2>/dev/null | head -4) || hits=""
[ -n "$hits" ] || continue
count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0)
label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name")
files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//')
local_lines="${local_lines}> - \`${label}\` is already defined in ${count} other file(s): ${files}"$'\n'
done <<< "$names"
local_lines=$(scribe_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
[ -n "$local_lines" ] && local_lines="${local_lines}"$'\n'
fi
local_context=""
@@ -206,11 +121,7 @@ if [ -n "$shapes" ]; then
[ -n "$enc" ] && shapes_q="&shapes=${enc}"
fi
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
# Guard against an unexpanded ${...} placeholder arriving as a literal.
case "$url" in *'${'*) url="" ;; esac
case "$token" in *'${'*) token="" ;; esac
scribe_config || : # sets url/token; unconfigured is handled just below
# Unconfigured install → the recorded-prior-art arms are skipped, but the local
# arm above already ran and may have something to say.
if [ -z "$url" ] || [ -z "$token" ]; then
@@ -258,14 +169,22 @@ fi
# the sync nudge when the recorded file itself is edited later.
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
mkdir -p "$state_dir" 2>/dev/null || true
#
# A THIRD channel (#2900): the ledger's derive arm names a duplicate family
# (a derive group id) or a canon elsewhere (`canon:<snippet_id>`) for the
# shapes being written. Keyed by that token, not a note id, so it dedups on
# its own file and a family is named once per session, not at every edit.
idfile=""
syncfile=""
derivefile=""
exclude_q=""
sync_exclude_q=""
derive_exclude_q=""
if [ -n "$session_id" ]; then
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
idfile="$state_dir/${safe_sid}.ids"
syncfile="$state_dir/${safe_sid}.sync.ids"
derivefile="$state_dir/${safe_sid}.derive.ids"
if [ -f "$idfile" ]; then
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
@@ -274,13 +193,26 @@ if [ -n "$session_id" ]; then
sync_seen=$(tr '\n' ',' < "$syncfile" 2>/dev/null | sed 's/,$//')
[ -n "$sync_seen" ] && sync_exclude_q="&exclude_sync_ids=${sync_seen}"
fi
if [ -f "$derivefile" ]; then
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | jq -sRr '@uri' 2>/dev/null) || derive_seen=""
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
fi
fi
# `|| true`, not `|| exit 0`: an unreachable instance must not discard a local
# finding that needed no instance to produce.
# Not `|| exit 0`: an unreachable instance must not discard a local finding
# that needed no instance to produce. And not silence either (#2932): a call
# that was owed and didn't come back is said, once per outage, so the session
# knows this write went unchecked.
reached=1
body=$(curl -fsS --max-time 5 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${shapes_q}" 2>/dev/null) || body=""
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${shapes_q}" 2>/dev/null) || { body=""; reached=0; }
unreached_context=""
if [ "$reached" = 1 ]; then
scribe_reached "$state_dir" "${safe_sid:-nosession}"
else
unreached_context=$(scribe_unreached "$state_dir" "${safe_sid:-nosession}" 5 "$rel_path")
fi
context=""
if [ -n "$body" ]; then
@@ -295,6 +227,9 @@ if [ -n "$body" ]; then
if [ -n "$syncfile" ]; then
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
fi
if [ -n "$derivefile" ]; then
printf '%s' "$body" | jq -r '(.derive_keys // [])[]?' 2>/dev/null >> "$derivefile" || true
fi
fi
fi
@@ -304,9 +239,10 @@ fi
# noise: the duplication is demonstrated, not guessed. Gated on BOTH sides so
# an ordinary new helper (no other copies) and an already-recorded one (the
# server spoke) stay nudge-free — a reflex that fires on everything is one
# that gets skipped. An unreachable server counts as "nothing recorded": the
# local finding needed no server, and the nudge fails open with it.
if [ -n "$local_lines" ]; then
# that gets skipped. A server that did not ANSWER earns no nudge (#2932): "none
# of those copies is recorded" is a claim only an answer can back — the
# unreached line says what actually happened instead.
if [ -n "$local_lines" ] && [ "$reached" = 1 ]; then
n_recorded=$(printf '%s' "$body" | jq -r '.note_ids | length' 2>/dev/null) || n_recorded=0
if [ "${n_recorded:-0}" = "0" ] || [ "$n_recorded" = "" ]; then
local_context="${local_context}"$'\n'"> None of those existing copies is recorded in Scribe. If the version being written is the canonical one — or this edit is consolidating the copies — record it now with create_snippet (name, code, when-to-reach-for-it, location) so the next session is offered it instead of writing another copy."
@@ -321,6 +257,10 @@ if [ -n "$context" ]; then
[ -n "$combined" ] && combined="${combined}"$'\n'
combined="${combined}${context}"
fi
if [ -n "$unreached_context" ]; then
[ -n "$combined" ] && combined="${combined}"$'\n'
combined="${combined}${unreached_context}"
fi
[ -n "$combined" ] || exit 0
# No permissionDecision: this is a nudge, not a gate. The write goes ahead.
+6 -7
View File
@@ -39,6 +39,9 @@
# allowed to fail quietly; see the #2198 comment at the status block below.
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
command -v jq >/dev/null 2>&1 || exit 0 # needed to emit the JSON envelope safely
# `CDPATH= cd` is deliberate, not a typo'd assignment: it runs this one `cd`
@@ -87,13 +90,9 @@ if [ -f "$manifest" ]; then
fi
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
# Guard against an unexpanded `${...}` placeholder reaching us as a literal — it
# would otherwise be sent as a garbage Bearer token and 401. Treat as unset.
case "$url" in *'${'*) url="" ;; esac
case "$token" in *'${'*) token="" ;; esac
# Unconfigured is NOT a failure here: tier 1's static floor is still owed,
# so this records the answer rather than acting on it.
scribe_config || :
dyn=""
status=""
+4 -1
View File
@@ -66,7 +66,10 @@ for the operator's work, and as your own working memory across sessions.
should read as a map of every shape in it. The backstop still holds:
noticing the second copy of anything, or consolidating copies into a shared
X, means X gets recorded before that work is finished — which is how a
codebase is kept from growing four `.btn-primary` definitions.
codebase is kept from growing four `.btn-primary` definitions. The write-path
hooks (before a Write/Edit, and after any Bash call that changed the tree)
name a known duplicate family or a canon elsewhere for what was just
written — act on that line at the write, not at the next audit.
- Do **not** keep the operator's rules, plans, or project notes in local
memory / CLAUDE.md in parallel with Scribe — Scribe holds the single copy.
- **Compact at clean seams** — because you record as you go, a context
+4 -6
View File
@@ -23,15 +23,13 @@
# #2198), with SCRIBE_URL / SCRIBE_TOKEN as the override.
set -uo pipefail
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
command -v jq >/dev/null 2>&1 || exit 0
command -v curl >/dev/null 2>&1 || exit 0
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
# Guard against an unexpanded `${...}` placeholder arriving as a literal.
case "$url" in *'${'*) url="" ;; esac
case "$token" in *'${'*) token="" ;; esac
[ -n "$url" ] && [ -n "$token" ] || exit 0
scribe_config || exit 0
body=$(curl -fsS --max-time 8 \
-H "Authorization: Bearer ${token}" \
+8
View File
@@ -44,6 +44,14 @@ through recall/auto-inject; this skill is the active reflex around that.
it before you go any further. Either it's the helper you were about to
duplicate — reuse it and drop yours — or it isn't, and the record needs the new
location adding. Both are cheaper now than after the duplicate settles in.
- **A `Shape ledger at …` line is the ledger speaking, not the record.** It
names a duplicate family ("identical body in N other files, no canon"), a
repeated name ("defined in N other files") or a canon elsewhere for a name
you just wrote — for edits made through Bash
(sed, heredocs, scripts) as much as through Write/Edit. Derive the family or
reuse the canon *now*; a family that is convention rather than copies is
dismissed with `classify_shapes(..., status="exempt",
reason_code="convention-plumbing")`, never ignored.
- **A `[records this file]` hint is a duty, not a menu.** When the hint says a
snippet records the very file you're editing, the record's freshness is now
YOUR edit's responsibility: if the edit changes the recorded shape,
+49
View File
@@ -85,6 +85,55 @@ the dominant form, `create_snippet` it, migrate the outliers, then classify
the rest as instances. Canon is determined from the code; consistency comes
from the derivation, not from asking permission.
## Derive groups are drift, not audit material
The catalogue exists so the codebase is DRY **from inception**, not as DRY as
the last sweep left it. Three surfaces say so without anyone running an audit
(milestone 299):
- **At the write** — the prior-art hint (the Write/Edit hook, and since
0.1.39 the after-write hook on Bash, so sed/heredoc/script edits count too)
carries a `Shape ledger at <path>` line when a name just written is a known
**duplicate family** ("identical body in N other files, no canon"), a
**repeated name** ("defined in N other files, no canon") or a
**canon elsewhere** ("snippet #N at <path> — reuse, don't redefine"). Act
on it *then*: pull the canon and build from it, or derive the family now —
`create_snippet` the dominant form, repoint the copies, `classify_shapes`
them `instance`. A family is named once per session.
- **On arrival** — the coverage line's `standing:` block (shown even when
nothing is unclassified) and `derive_new` ("+N new copies since last
refresh: .x in <path>") name what drifted since the previous refresh. That
is the todo of the moment, sized to the last batch — not a backlog.
- **A family that is convention, not copies** — component-local `load` /
`toggle` / `save` that happen to share a name — is dismissed, not
consolidated: `classify_shapes(..., status="exempt",
reason_code="convention-plumbing", reason=…)` (or `classify_shapes_by_rule`
for a whole family) removes it from the queue. Dismissal is a judgment and
it is recorded; silence is not.
- **CSS is watched by name, never by body** (note 2917). Classes serving
different purposes share declarations because the style system makes them
alike — `.text-muted` and `.pin-badge-auto` carrying the same `color:` are
two meanings, not two copies — so a CSS family is the *same class defined
in ≥2 files* (a recipe living in several places), and identical bodies
under different names are never a family. Derive a CSS family by moving
the recipe to the shared sheet and recording it; a class name reused for
genuinely different things is dismissed with `reason_code="scoped-css"`.
The datum that decides between the two is **what renders it**: every css
row carries `used_by` (the files whose markup names the class — the CSS
consumer map, milestone 302), a derive group carries the family's
`consumers`, and the write-path line says "used by N template(s)". Many
templates, one recipe → derive; one template each, different purposes →
dismiss. `list_shapes(flag="unused-css")` is the map's negative space —
css rules no template names, a deletion candidate to look at, never
auto-deleted. The map reads the two class forms templates don't spell out
— a `<Transition name="x">`'s generated classes, and the prefix of a
concatenated name (`` `status-${s}` `` credits every `status-…` rule) —
so what it flags is worth reading. What it still cannot see is a name
assembled in a script (`classList.add`), so confirm before deleting.
After the one-time pay-down the derive queue reads empty; anything in it
afterwards is drift of the moment, and the hint already said so at the write.
## The divergence readout — button B where button A is canon
Three questions the ledger answers mechanically (#2793):
+22
View File
@@ -120,6 +120,28 @@ bound — confine the session to it:
- If something clearly belongs to a *different* project, say so and **ask before
switching** — never silently operate cross-project.
## Starting a project: decide what it inherits
A project's inheritance is a **decision, not a default**. Before
`create_project`, ask the operator the four inception questions and pass the
answers — never create a project bare by default:
- which **always-on rulebooks** it should NOT inherit (`list_rulebooks` shows
which are always_on; default: inherit them all) →
`exclude_always_on_rulebooks=[...]`
- which other rulebooks to **subscribe**`subscribe_rulebooks=[...]`
- which **design system** its UI is built from (`list_design_systems`; or
none) → `design_system_id=<id | -1>`
- whether to **seed the standard starter Systems** so records can be tagged
from day one → `seed_systems=true|false`
If `enter_project` returns an `inception` key, the project was never decided
(it inherits its defaults silently): raise that ask once, with the defaults it
carries, then `decide_project_inception(project_id, …)`. Existing projects
were stamped "legacy" (inherit-all) and do not ask; any project can be
re-decided. The rules/design-system/Systems tools still work one at a time —
inception is the moment they are decided together, and the record of why.
## Where a new rule goes
When codifying a rule, pick its home by **who it should bind** — and keep
+47 -8
View File
@@ -159,7 +159,12 @@ def check_shellcheck() -> None:
return
for script in hook_scripts():
proc = subprocess.run(
[exe, "--severity=warning", "--shell=bash", str(script)],
# -x FOLLOWS `# shellcheck source=` directives into the sourced
# file. Without it the shared helpers in scribe_defs.sh are
# invisible, so every variable they set reads as unassigned
# (SC2154) and every bug inside them goes unlinted at the call
# site — which is the opposite of what sharing them was for.
[exe, "--severity=warning", "--shell=bash", "-x", str(script)],
capture_output=True, text=True,
)
rel = script.relative_to(ROOT)
@@ -172,15 +177,24 @@ def check_shellcheck() -> None:
# --- the fail-open contract ------------------------------------------------
# Every hook promises never to break the operator's session: unconfigured or
# unreachable, it exits 0. Three of them additionally promise SILENCE, because
# they are pure enrichment. scribe_session_context.sh is the exception by
# design — it always emits a static behavioural floor that needs no credentials
# and no network, so "silent" would be the wrong assertion for it.
# unreachable, it exits 0. Unconfigured, the enrichment hooks are SILENT — no
# call was owed. scribe_session_context.sh is the exception by design — it
# always emits a static behavioural floor that needs no credentials and no
# network, so "silent" would be the wrong assertion for it.
#
# UNREACHABLE is different for the two write-path hooks since #2932: a call
# that was owed and did not come back is SAID, once per outage ("> Scribe did
# not answer …"), so a session can tell "checked, nothing there" from "never
# checked". That line — or silence, when the once-per-outage marker in
# ${TMPDIR:-/tmp}/scribe-priorart/ was set by a run in the last ten minutes —
# is the only output allowed with no working instance; anything else is a hook
# speaking on data it cannot have.
#
# This is the contract that made #2198 invisible for weeks, so it is worth
# pinning: the bug and the healthy no-results case look identical from outside.
# Pinning it does NOT make the failure visible; it makes sure the fail-open
# behaviour is deliberate rather than accidental.
# pinning: the bug and the healthy no-results case looked identical from
# outside. #2932 is what finally makes the failure visible at the write; this
# check makes sure the fail-open behaviour stays deliberate rather than
# accidental.
# A symbol that exists nowhere, ASSEMBLED rather than written literally.
# The prior-art hook's local arm (#2280) fires with no credentials, so the
# silence assertion below needs a name the repo genuinely lacks. Two traps,
@@ -203,10 +217,24 @@ SMOKE_EVENTS: dict[str, str] = {
),
"scribe_sync_processes.sh": json.dumps({"source": "startup"}),
"scribe_session_context.sh": json.dumps({"source": "startup"}),
# The after-write hook (#2901) diffs the working tree; on CI's clean
# checkout there is nothing to report, so silence is the right assertion.
# (On a dirty local tree with a definition just written it may speak —
# that is the hook working, not a failure of the contract.)
"scribe_after_write.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
"tool_input": {"command": "true"}, "tool_response": {}}
),
# The shared library is sourced, never run; executed bare it defines
# functions and exits — silent by construction.
"scribe_defs.sh": "",
}
# The one hook that legitimately produces output with no credentials.
STATIC_FLOOR = "scribe_session_context.sh"
# The hooks that say so when a configured instance does not answer (#2932).
OUTAGE_SPEAKERS = {"scribe_prior_art.sh", "scribe_after_write.sh"}
OUTAGE_LINE = "> Scribe did not answer the prior-art check"
def _run_hook(script: Path, event: str, env_extra: dict[str, str]) -> subprocess.CompletedProcess:
@@ -258,6 +286,17 @@ def check_fail_open() -> None:
f"behavioural floor must survive having no credentials")
else:
ok(f"{rel} [{label}]: exit 0, static floor present")
elif out and label == "unreachable" and script.name in OUTAGE_SPEAKERS:
# The only thing allowed here is the outage line itself.
try:
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
except (ValueError, KeyError, TypeError):
ctx = ""
if ctx.startswith(OUTAGE_LINE):
ok(f"{rel} [{label}]: exit 0, says the instance did not answer")
else:
fail(f"{rel} [{label}]: emitted output with no working instance "
f"that is not the outage line:\n {out[:200]}")
elif out:
fail(f"{rel} [{label}]: emitted output with no working instance:\n"
f" {out[:200]}")
+10 -6
View File
@@ -37,24 +37,23 @@ in local files (CLAUDE.md, auto-memory); Scribe holds the single copy.
Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
- ORIENT: enter_project(id) at session start — rules, open tasks, recent
notes, Systems and design system in one call.
notes, Systems, design system. `inception` key: ask what the project
inherits, decide_project_inception (create_project takes the same).
- DO: create_task. Fixed a problem? kind="issue" (symptom -> root cause ->
fix), never a work-log line on an unrelated task. Log with add_task_log;
keep status honest — in_progress on start, done on finish.
- PLAN work with an arc: start_planning. The plan IS a milestone; each step is
a child task, not a checkbox. No local plan .md files.
- CAPTURE: create_note. RECALL: search first, before answering about the
operator's work or opening a task — assume prior art exists, and pass the
- CAPTURE: create_note. RECALL: search first — prior art exists; pass the
active project_id to stay in scope.
- WHERE work happens: Systems. Tag records with system_ids as you write;
create_system when the area is unmodelled.
- HOW to work: rules are pull-only and binding — call list_always_on_rules()
yourself at session start.
- HOW: rules are binding — list_always_on_rules() at session start.
- UI: the project's design system is binding — resolve_design_system /
get_design_system_stylesheet before hand-writing a value.
- REUSE: search snippets before writing a helper; record what you build with
create_snippet; classify shapes against canon (classify_shapes) — a
consumer map is rows, never prose. Saved procedures are Processes (follow
consumer map is rows, never prose. Processes are saved procedures (follow
verbatim). Deletes are trash-recoverable.
A task is a note with status (*_note vs *_task tools).
@@ -113,6 +112,11 @@ _READ_ONLY_TOOLS = frozenset({
# The shape ledger's todo query (#2789). Reads only — classify_shapes is
# the write, and it is deliberately NOT here.
"list_shapes", "shape_history",
# The retrieval telemetry readout (#2975). Aggregates two log tables and
# writes nothing. Listed explicitly because its name carries no read
# prefix, so the completeness test below cannot derive it — the same
# reason `enter_project` is spelled out above.
"retrieval_telemetry",
})
# Read-SHAPED tools that must NOT be reachable with a read key — a getter that
+7 -2
View File
@@ -15,13 +15,17 @@ from scribe.services import trash as trash_svc
from scribe.services.note_usage import record_pulled
async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
async def list_processes(
q: str = "", tag: str = "", limit: int = 50, offset: int = 0,
) -> dict:
"""List stored processes (reusable saved prompts).
Args:
q: Free-text search across title + body (optional).
tag: Filter to a single tag (optional).
limit: Max results (1-100).
offset: Skip this many before returning — page past the cap.
`total` is the unpaged count, so it says whether more remains.
Returns {"processes": [{id, title, tags, preview}], "total": int}. An entry
marked `shared: true` with an `owner` is another person's procedure — treat
@@ -34,7 +38,8 @@ async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
uid = current_user_id()
items, total = await knowledge_svc.query_knowledge(
user_id=uid, note_type="process", tags=[tag] if tag else [],
sort="modified", q=q or None, limit=max(1, min(limit, 100)), offset=0,
sort="modified", q=q or None, limit=max(1, min(limit, 100)),
offset=max(0, offset),
)
labelled = await access_svc.label_shared_items(uid, items)
procs = [{"id": it["id"], "title": it["title"], "tags": it.get("tags", []),
+102 -3
View File
@@ -20,6 +20,7 @@ from scribe.mcp._context import current_user_id
from scribe.mcp.tools import systems as systems_tools
from scribe.services import coverage as coverage_svc
from scribe.services import design_systems as design_systems_svc
from scribe.services import inception as inception_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
@@ -80,6 +81,12 @@ async def enter_project(project_id: int) -> dict:
create it with create_system rather than leaving the area unmodelled. Read
a subsystem's accumulated records with list_system_records.
`inception` (milestone 297) appears ONLY when the project is yours and
nobody has decided what it inherits: it carries the current defaults
(which always-on rulebooks bind, design system, Systems), what to ask the
operator — once — and the decide_project_inception call that answers it;
it repeats on every enter until a decision is recorded.
`systems_bootstrap` appears ONLY when the project has many records and no
Systems at all — act on it before starting other work: create_system a
starter vocabulary from the areas the project's records name, directly
@@ -141,6 +148,14 @@ async def enter_project(project_id: int) -> dict:
uid, project_id
)
# The inception ask (milestone 297): a project nobody has decided on
# inherits its defaults silently — always-on rulebooks, no design system,
# no Systems. Owner-only (deciding is the owner's), and only until a
# decision is recorded; the key is ABSENT otherwise (#2483).
inception_ask = None
if project.user_id == uid and not inception_svc.is_decided(project):
inception_ask = await inception_svc.inception_ask(uid, project_id)
# Probably the largest surfacing by volume, and it emitted nothing — so
# the pulls it caused floated unattributed and the surfaced:pulled ratio
# ran against a denominator missing its biggest contributor (#2477). An
@@ -213,6 +228,8 @@ async def enter_project(project_id: int) -> dict:
# readers to skip it (#2483), and this one exists to be acted on.
if systems_bootstrap:
out["systems_bootstrap"] = systems_bootstrap
if inception_ask:
out["inception"] = inception_ask
return out
@@ -238,14 +255,43 @@ async def get_project(project_id: int) -> dict:
return data
def _inception_choices(
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
) -> dict | None:
"""The tool args → an inception choices object, or None when no inception
arg was given at all (a bare create stays undecided and enter_project
asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
system."""
if (exclude_always_on_rulebooks is None and subscribe_rulebooks is None
and not design_system_id and seed_systems is None):
return None
return {
"exclude_always_on_rulebooks": list(exclude_always_on_rulebooks or []),
"subscribe_rulebooks": list(subscribe_rulebooks or []),
"design_system_id": None if design_system_id in (0, -1) else design_system_id,
"seed_systems": bool(seed_systems),
}
async def create_project(
title: str,
description: str = "",
goal: str = "",
status: str = "active",
color: str = "",
exclude_always_on_rulebooks: list[int] | None = None,
subscribe_rulebooks: list[int] | None = None,
design_system_id: int = 0,
seed_systems: bool | None = None,
) -> dict:
"""Create a new project in Scribe.
"""Create a new project in Scribe — and decide what it inherits.
A project's inheritance is a decision, not a default (milestone 297):
before calling, ask the operator the four inception questions and pass
the answers; a project created without any of them is UNDECIDED and
enter_project will ask until decide_project_inception records it.
Defaults if nobody decides: every always-on rulebook binds, nothing is
subscribed, no design system, no Systems.
Args:
title: Project name (required).
@@ -253,6 +299,14 @@ async def create_project(
goal: The desired outcome or definition of done for the project.
status: one of active (default), paused, completed, archived.
color: Optional hex colour for the project card (e.g. "#6366f1").
exclude_always_on_rulebooks: always-on rulebook ids this project does
NOT inherit ([] = inherit them all). list_rulebooks shows which are
always_on.
subscribe_rulebooks: rulebook ids to subscribe (the non-always-on ones).
design_system_id: the design system this project's UI is built from
(list_design_systems); -1 = explicitly none; 0 = not stated.
seed_systems: true mints the standard starter Systems (CI & Release,
Auth & Access, …) so records can be tagged from day one.
"""
uid = current_user_id()
project = await projects_svc.create_project(
@@ -263,7 +317,52 @@ async def create_project(
status=status,
color=color or None,
)
return project.to_dict()
data = project.to_dict()
choices = _inception_choices(
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
)
if choices is not None:
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
data["inception"] = decided["inception"]
data["inception_effects"] = decided["effects"]
else:
data["inception_hint"] = (
"Undecided: this project inherits its defaults until "
"decide_project_inception records what it should inherit "
"(enter_project will ask)."
)
return data
async def decide_project_inception(
project_id: int,
exclude_always_on_rulebooks: list[int] | None = None,
subscribe_rulebooks: list[int] | None = None,
design_system_id: int = 0,
seed_systems: bool | None = None,
) -> dict:
"""Record what a project inherits — answer enter_project's `inception` ask,
or re-decide later (milestone 297).
Owner-only. Applies the effects through the ordinary tools' paths —
exclude_always_on_rulebook, subscribe_project_to_rulebook,
set_project_design_system, the standard Systems seed — and writes the
decision on the project last, so get_project/enter_project can say why
the project has the rules, design and Systems it has. Re-deciding is
additive for exclusions/subscriptions (use include_always_on_rulebook /
unsubscribe_project_from_rulebook to undo one), replaces the design
system, and never re-seeds Systems a project already has.
Args: as create_project's inception args. Passing nothing records an
inherit-all decision (every always-on rulebook binds, no subscriptions,
no design system, no seed) — a valid answer, stated.
"""
uid = current_user_id()
choices = _inception_choices(
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
) or {}
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
return {"project_id": project_id, **decided}
async def update_project(
@@ -320,6 +419,6 @@ def register(mcp) -> None:
get_project,
create_project,
update_project,
delete_project,
delete_project, decide_project_inception,
):
mcp.tool(name=fn.__name__)(fn)
+38 -2
View File
@@ -222,16 +222,22 @@ async def list_rules(
return {"rules": [_rule_summary(r) for r in rows], "total": len(rows)}
async def list_always_on_rules() -> dict:
async def list_always_on_rules(project_id: int = 0) -> dict:
"""Return all rules from rulebooks flagged always_on for the current user.
Call this at session start. Treat the returned rules as binding for the
session — they apply regardless of which project (if any) is in scope.
Pair with get_project(id).applicable_rules when working on a specific
project to also load that project's subscription-derived rules.
Args:
project_id: 0 (default) = the user-wide set. Inside a project, pass
its id: an always-on rulebook the project EXCLUDED at inception
(see enter_project's `excluded_always_on`) is left out — the
project decided not to inherit it.
"""
uid = current_user_id()
rules = await rulebooks_svc.list_always_on_rules(uid)
rules = await rulebooks_svc.list_always_on_rules(uid, project_id=project_id)
return {"rules": [_rule_summary(r) for r in rules], "total": len(rules)}
@@ -407,6 +413,35 @@ async def unsubscribe_project_from_rulebook(
# ── Suppressions — project-level mute of rulebook rules / topics ────────
async def exclude_always_on_rulebook(project_id: int, rulebook_id: int) -> dict:
"""Opt a project OUT of a whole always-on rulebook (milestone 297).
Always-on rulebooks bind every project implicitly; an inception decision
can say "not this one, not here". The exclusion is total for that project
— list_always_on_rules(project_id), enter_project/get_project rules and
the session-start context all leave it out and name it under
`excluded_always_on`. Owner-only; the rulebook must be always_on (a
subscribed rulebook is left with unsubscribe_project_from_rulebook).
Idempotent; include_always_on_rulebook reverses it. Normally reached via
decide_project_inception, not by hand.
"""
uid = current_user_id()
await rulebooks_svc.exclude_always_on_rulebook_for_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
)
return {"project_id": project_id, "rulebook_id": rulebook_id, "excluded": True}
async def include_always_on_rulebook(project_id: int, rulebook_id: int) -> dict:
"""Reverse exclude_always_on_rulebook: the always-on rulebook binds this
project again. Idempotent."""
uid = current_user_id()
await rulebooks_svc.include_always_on_rulebook_for_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
)
return {"project_id": project_id, "rulebook_id": rulebook_id, "excluded": False}
async def suppress_rule_for_project(
project_id: int, rule_id: int,
) -> dict:
@@ -470,5 +505,6 @@ def register(mcp) -> None:
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
suppress_rule_for_project, unsuppress_rule_for_project,
suppress_topic_for_project, unsuppress_topic_for_project,
exclude_always_on_rulebook, include_always_on_rulebook,
):
mcp.tool(name=fn.__name__)(fn)
+48 -1
View File
@@ -12,7 +12,7 @@ import time
from scribe.mcp._context import current_user_id
from scribe.services.access import owner_names_for
from scribe.services.embeddings import DEFAULT_SIMILARITY_THRESHOLD, semantic_search_notes
from scribe.services.retrieval_telemetry import record_retrieval
from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summary
async def search(
@@ -95,5 +95,52 @@ async def search(
}
async def retrieval_telemetry(days: int = 30) -> dict:
"""What the retrieval telemetry says about YOUR surfaces, over a window.
The read half of the loop the ranker's thresholds are meant to be tuned
from (#2975). Reach for it before changing a similarity threshold, a top-k,
or deciding whether a reranker is worth building — the alternative is
hand-probing the live instance, which is how the last such decision had to
be made.
Two readouts, from the two tables built for them:
`sources` — per retrieval surface (`auto_inject`, `write_path`,
`mcp_search`, …), from `retrieval_logs`: `calls`, `zero_result_calls`,
`cleared_threshold` (how often the best hit beat the threshold in force for
that call), the `top_score` spread (p10/p50/p90/min/max), `avg_result_count`
and `p90_duration_ms`. THE number to read first is `cleared_threshold`
against `calls`, with the spread beside it: a surface that clears its bar
on nearly every call is either well-tuned or too loose, and p10 says which.
`usage` — from `note_usage_events`, at the per-note grain
`retrieval_logs` cannot be indexed at: `surfaced` (ranked surfacings — a
scored surface CHOSE the record), `ambient` (the rest), `pulled` split into
`pulled_by_agent` / `pulled_by_human`, the distinct-note counts, and
`pull_through`. That ratio is the corpus-side precision signal: records
surfaced often and opened never are dead weight competing for the injection
budget every turn.
`pull_through` is AGENT pulls over RANKED surfacings, and both halves of
that matter. "Is this record dead weight?" is answered by any pull; "was
that injected line useful?" — the question a threshold or a reranker is
tuned against — only by a pull the agent made. Aggregating across the
mcp_/rest_ prefix would silently answer the wrong one.
Scoped to your own telemetry — a retrieval log records what your agent
asked for, query text included, and is not a shared record kind.
`read_failed: true` means the query itself failed — deliberately distinct
from an empty window, because those two looked identical for weeks once
(#2663) and every counter silently read zero.
Args:
days: window size, default 30. Clamped to at least 1.
"""
return await retrieval_summary(current_user_id(), days=days)
def register(mcp) -> None:
mcp.tool(name="search")(search)
mcp.tool(name="retrieval_telemetry")(retrieval_telemetry)
+23 -7
View File
@@ -116,10 +116,18 @@ async def list_shapes(
classify it: instance if it should use the canon, variant with
the why if deliberate); "recheck": judged instances/variants
whose body changed since judged (the judgment stands; confirm
it again with classify_shapes, or re-judge).
it again with classify_shapes, or re-judge); "unused-css"
(milestone 302): live css rules no file's markup names — a
deletion candidate to look at, never auto-deleted. Transition
classes and concatenated names are read (#2970), so the list is
worth acting on; a name assembled in a script still is not.
Returns {"shapes": [...], "total": N} — total counts every match, not
just this page. Each row's `classified_by` says who judged: agent /
just this page. Every css row carries `used_by` {count, paths} — the
files whose markup names its class (milestone 302, the CSS consumer
map: a scoped rule is used by its own template; a shared recipe by
many; a count of 0 is "no template names it"). Each row's
`classified_by` says who judged: agent /
audit / import are judgments; `mechanical` is the canonical stamp the
sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled
a snippet and then wrote code referencing/resembling it, so the shape
@@ -145,10 +153,12 @@ async def list_shapes(
include_vanished=include_vanished, limit=limit, offset=offset,
proposal=proposal, flag=flag, uses=uses,
)
return {
"shapes": [r.to_compact() if compact else r.to_dict() for r in rows],
"total": total,
}
shapes = [r.to_compact() if compact else r.to_dict() for r in rows]
used_by = await shape_ledger_svc.used_by_map(rows)
for row, out in zip(rows, shapes):
if row.id in used_by:
out["used_by"] = used_by[row.id]
return {"shapes": shapes, "total": total}
async def classify_shapes_by_rule(
@@ -295,7 +305,13 @@ async def refresh_pattern_coverage(project_id: int) -> dict:
Returns the accounting payload — total, accounted, counts by status,
unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting
confirmation), `derive_groups` (the biggest repeats-with-no-canon
families), `proposer` (what this refresh examined) — plus
families, each css one with `consumers` — the files whose markup
render it, milestone 302), `unused_css` (css rules no template names —
counting a `<Transition name=>`'s generated classes and concatenated
names as named, #2970; None where the map has no evidence of templates), `derive_new` (copies
that joined a family since the previous
refresh — the drift to act on now: derive the canon, don't queue an
audit), `proposer` (what this refresh examined) — plus
`pattern_coverage`, the same one-line summary enter_project carries.
"""
uid = current_user_id()
+13 -1
View File
@@ -20,7 +20,8 @@ from scribe.services import systems as systems_svc
async def list_snippets(
q: str = "", tag: str = "", limit: int = 50, project_id: int = 0,
q: str = "", tag: str = "", limit: int = 50, offset: int = 0,
project_id: int = 0,
repo: str = "", path: str = "", symbol: str = "", verification: str = "",
) -> dict:
"""List recorded snippets — the project's pattern library.
@@ -41,6 +42,9 @@ async def list_snippets(
well as wording, so describe what you need the code to DO.
tag: Filter to a single tag, e.g. a language like "python" (optional).
limit: Max results (1-100).
offset: Skip this many before returning — page through a corpus
larger than one call. `total` is the unpaged count, so
offset+limit against it says whether more remains.
project_id: Narrow to one project. 0 (default) searches every project —
usually what you want, since a helper you need here may well have
been written somewhere else.
@@ -81,6 +85,7 @@ async def list_snippets(
uid = current_user_id()
items, total = await snippets_svc.list_snippets(
uid, q=q or None, tag=tag, limit=max(1, min(limit, 100)),
offset=max(0, offset),
project_id=project_id or None,
repo=repo, path=path, symbol=symbol, verification=verification,
)
@@ -207,6 +212,13 @@ async def get_snippet(snippet_id: int) -> dict:
the source moved on — trust the location over the cached body and
consider verify_snippet after you look.
A record kept VERBATIM is confirmed by containment. A deliberately
ANNOTATED one — commentary the source does not carry — cannot be, so it
reads "current" on the authority of a standing `ok` verdict stamped at
the very commit just fetched (#2782); `verification` in the same payload
shows that basis. Edit the record, or let the file move past that commit,
and it reads "diverged" again until someone re-runs verify_snippet.
When the shape ledger has judgments against this snippet, the response
carries `instances` (shapes classified as conforming to it — the
structured consumer map) and/or `variants` (named departures, each with
+4 -4
View File
@@ -30,10 +30,10 @@ _BOOTSTRAP_TITLES = 6
# design (rule #115): archetypes any codebase could have, never one
# install's subsystems. Mint freely beyond the list; the duplicate gate
# guards sprawl.
_STANDARD_SYSTEMS = (
"CI & Release", "Auth & Access", "Data Model & Storage", "API Surface",
"UI & Design", "Import & Export", "Background Jobs", "Observability",
)
# The standard vocabulary lives with the service (services/systems.
# STANDARD_SYSTEMS) since milestone 297 — the inception seed mints it and this
# ask names it, one list for both.
_STANDARD_SYSTEMS = tuple(name for name, _charter in systems_svc.STANDARD_SYSTEMS)
async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
+1 -1
View File
@@ -44,6 +44,6 @@ from scribe.models.rulebook import ( # noqa: E402, F401
)
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse # noqa: E402, F401
from scribe.models.code_shape import CodeShape, CodeShapeConsumer, CodeShapeEvent, CodeShapeUse # noqa: E402, F401
from scribe.models.system import System, RecordSystem # noqa: E402, F401
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
+46
View File
@@ -265,6 +265,52 @@ class CodeShapeUse(Base):
}
# How a consumer edge was established (milestone 302). `template` is the
# sync's mechanical read of a file's markup (class= / :class= / className=);
# the vocabulary is a list so a later basis (a stylesheet `@apply`, a script's
# classList) has a name without a schema change.
CONSUMER_BASES = ("template",)
class CodeShapeConsumer(Base):
"""One consumer edge: CSS shape → the file whose markup names its class
(milestone 302; note 2917 — CSS is watched by name, by recipe, by token
and by WHAT USES IT). The analogue of CodeShapeUse for styling: `uses`
says what a shape calls, this says who renders a class. Rows, not prose,
so "is this recipe shared or scoped?" is a count, not a guess.
Mechanical and fully recomputable: every coverage sync rebuilds a repo's
edges from its archive, so the table is not backed up (see
services/backup._NOT_INCLUDED). Cascades with the shape.
"""
__tablename__ = "code_shape_consumers"
__table_args__ = (
UniqueConstraint("shape_id", "path", name="uq_code_shape_consumers_shape_path"),
)
id: Mapped[int] = mapped_column(primary_key=True)
shape_id: Mapped[int] = mapped_column(
Integer, ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False
)
path: Mapped[str] = mapped_column(Text, nullable=False)
count: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
basis: Mapped[str] = mapped_column(Text, nullable=False, default="template")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
def to_dict(self) -> dict:
return {
"id": self.id,
"shape_id": self.shape_id,
"path": self.path,
"count": self.count,
"basis": self.basis,
"created_at": iso(self.created_at),
}
# What a shape's history records (#2793). Not "appeared" — first_seen and
# created_at already say that on the row; history is for what CHANGED:
SHAPE_EVENTS = ("classified", "vanished", "reappeared", "drifted")
+10
View File
@@ -1,5 +1,6 @@
import enum
from sqlalchemy import BigInteger, ForeignKey, Integer, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
@@ -36,6 +37,14 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
BigInteger, ForeignKey("forge_connections.id", ondelete="SET NULL"),
nullable=True,
)
# The inception record (milestone 297): what this project was decided to
# inherit, when, and through which door — {decided_at, decided_by, via,
# choices: {exclude_always_on_rulebooks, subscribe_rulebooks,
# design_system_id, seed_systems}}. NULL means nobody has decided yet,
# and enter_project asks; the effects themselves live in the subscription
# / exclusion tables, design_system_id and the project's Systems — this is
# the WHY, kept so later surfaces can say it. See services/inception.py.
inception: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
def to_dict(self) -> dict:
return {
@@ -48,6 +57,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
"color": self.color,
"design_system_id": self.design_system_id,
"forge_connection_id": self.forge_connection_id,
"inception": self.inception,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
+13
View File
@@ -129,6 +129,19 @@ project_rule_suppressions = Table(
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
# A project's opt-out of a whole ALWAYS-ON rulebook (milestone 297): the
# sibling of the two suppression tables below, one level up. Always-on
# rulebooks bind every project implicitly; an inception decision can exclude
# specific ones for this project, and get_applicable_rules /
# list_always_on_rules(project_id) skip them. FKs CASCADE like the others.
project_rulebook_exclusions = Table(
"project_rulebook_exclusions",
Base.metadata,
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
project_topic_suppressions = Table(
"project_topic_suppressions",
Base.metadata,
+8
View File
@@ -129,6 +129,10 @@ async def write_path_prior_art():
surfaced. A separate channel on purpose: a reuse
hint shown early must not suppress the record-sync
nudge when the recorded file is edited later.
exclude_derive (opt) — comma-separated derive keys (a derive group id
or `canon:<snippet_id>`) already named this
session by the ledger arm (#2900); its own
channel, like the two above.
shapes (opt) — comma-separated `kind:name` definitions the hook
found in (or enclosing) the payload, kind being
css|sym. The shape ledger's write-path feed
@@ -144,6 +148,9 @@ async def write_path_prior_art():
project_id, repo, _unbound = await _project_scope()
exclude_ids = _int_list(request.args.get("exclude_ids"))
exclude_sync_ids = _int_list(request.args.get("exclude_sync_ids"))
exclude_derive = [
p.strip() for p in (request.args.get("exclude_derive") or "").split(",") if p.strip()
]
shapes = _parse_shapes(request.args.get("shapes") or "")
api_key = getattr(g, "api_key", None)
may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
@@ -153,6 +160,7 @@ async def write_path_prior_art():
exclude_ids=exclude_ids, exclude_sync_ids=exclude_sync_ids,
stamp_shapes=shapes if may_stamp else None,
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
exclude_derive=exclude_derive,
)
return jsonify(result)
+48 -1
View File
@@ -5,6 +5,7 @@ from quart import Blueprint, g, jsonify, request
from scribe.auth import login_required, get_current_user_id
from scribe.routes.utils import not_found, parse_pagination
from scribe.services import inception as inception_svc
from scribe.services.milestones import list_milestones
from scribe.services.notes import list_notes
from scribe.services.projects import (
@@ -66,6 +67,15 @@ async def create_project_route():
status = data.get("status", "active")
if status not in ("active", "paused", "completed", "archived"):
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
# The inception decision rides the create (milestone 297): the UI's
# second step sends `inception: {choices}`; absent = undecided, and the
# project page shows the card until it is. Validated before the create
# so a bad decision never leaves a half-made project behind.
inception = data.get("inception")
if inception is not None:
error = inception_svc.validate_inception(inception)
if error:
return jsonify({"error": error}), 400
project = await create_project(
uid,
title=data["title"],
@@ -74,7 +84,44 @@ async def create_project_route():
color=data.get("color"),
status=status,
)
return jsonify(project.to_dict()), 201
out = project.to_dict()
if inception is not None:
try:
decided = await inception_svc.decide(uid, project.id, choices=inception, via="ui")
except ValueError as exc:
return jsonify({"error": str(exc), "project": out}), 400
out["inception"] = decided["inception"]
out["inception_effects"] = decided["effects"]
return jsonify(out), 201
@projects_bp.route("/<int:project_id>/inception", methods=["POST"])
@login_required
async def decide_inception_route(project_id: int):
"""Record (or re-record) what a project inherits — milestone 297.
Body: the choices object {exclude_always_on_rulebooks, subscribe_rulebooks,
design_system_id, seed_systems}; owner-only."""
uid = get_current_user_id()
data = await request.get_json() or {}
choices = data.get("choices", data)
try:
decided = await inception_svc.decide(uid, project_id, choices=choices, via="ui")
except ValueError as exc:
msg = str(exc)
status = 404 if "not found" in msg else 400
return jsonify({"error": msg}), status
return jsonify({"project_id": project_id, **decided})
@projects_bp.route("/<int:project_id>/inception/defaults", methods=["GET"])
@login_required
async def inception_defaults_route(project_id: int):
"""What the project inherits if nobody decides — the card's payload."""
uid = get_current_user_id()
try:
return jsonify(await inception_svc.current_defaults(uid, project_id))
except ValueError:
return not_found("Project")
@projects_bp.route("/<int:project_id>", methods=["GET"])
+26
View File
@@ -288,6 +288,32 @@ async def unsuppress_project_topic(project_id: int, topic_id: int):
return "", 204
@rulebooks_bp.post("/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>")
@login_required
async def exclude_project_rulebook(project_id: int, rulebook_id: int):
"""Opt the project out of a whole always-on rulebook (milestone 297)."""
try:
await rulebooks_svc.exclude_always_on_rulebook_for_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
)
except ValueError as exc:
msg = str(exc)
return jsonify({"error": msg}), (400 if "not always-on" in msg else 404)
return "", 204
@rulebooks_bp.delete("/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>")
@login_required
async def include_project_rulebook(project_id: int, rulebook_id: int):
try:
await rulebooks_svc.include_always_on_rulebook_for_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.post("/projects/<int:project_id>/rules")
@login_required
async def create_project_rule(project_id: int):
+67 -4
View File
@@ -19,6 +19,7 @@ from scribe.models.rulebook import (
Rulebook,
RulebookTopic,
project_rule_suppressions,
project_rulebook_exclusions,
project_rulebook_subscriptions,
project_topic_suppressions,
)
@@ -45,8 +46,10 @@ logger = logging.getLogger(__name__)
# v9 (2026-08) added code_shape_uses — the ledger's consumption edges (#2870):
# judgment-grade edges (agent/audit/import) are operator records; mechanical
# ones (reference/hook) travel too, cheaply, and the next refresh refreshes them.
# v10 (2026-08) added projects.inception + project_rulebook_exclusions
# (milestone 297): the WHY a project inherits what it does, and its opt-outs.
# Bump when the serialized schema changes.
BACKUP_VERSION = 9
BACKUP_VERSION = 10
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
# below, these two lists must together account for the entire schema — which is
@@ -60,7 +63,7 @@ _BACKED_UP = [
"users", "projects", "milestones", "notes", "task_logs", "note_drafts",
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
"project_rulebook_subscriptions", "project_rule_suppressions",
"project_topic_suppressions",
"project_topic_suppressions", "project_rulebook_exclusions",
# v5 (2026-08): the five-year gap this list was written to stop.
"systems", "record_systems", "design_systems", "design_tokens",
"note_usage_events", "repo_bindings", "note_supersessions",
@@ -89,6 +92,10 @@ _NOT_INCLUDED = [
# deliberately not exported either, so restored projects fall back to
# keyring-by-host resolution — the documented unpinned behavior (#2778).
"forge_connections",
# Derived, like note_embeddings: the CSS consumer map (milestone 302) is
# rebuilt from the repo archive by every coverage sync, and carries no
# judgment — the first refresh after a restore recreates it exactly.
"code_shape_consumers",
]
@@ -112,6 +119,10 @@ def _topic_suppression_rows(rows) -> list[dict]:
return [{"project_id": r.project_id, "topic_id": r.topic_id} for r in rows]
def _rulebook_exclusion_rows(rows) -> list[dict]:
return [{"project_id": r.project_id, "rulebook_id": r.rulebook_id} for r in rows]
# The v5 sections. Pure row-builders like the join-table helpers above, for the
# same reason: CI has no database, so a serialiser that is a plain function is
# one that can actually be tested.
@@ -219,6 +230,8 @@ def _project_rows(rows) -> list[dict]:
"id": p.id, "user_id": p.user_id, "title": p.title,
"description": p.description, "goal": p.goal, "status": p.status,
"color": p.color,
"design_system_id": p.design_system_id,
"inception": p.inception,
"created_at": p.created_at.isoformat(),
"updated_at": p.updated_at.isoformat(),
}
@@ -383,6 +396,9 @@ async def export_full_backup() -> dict:
topic_suppressions = (await session.execute(
select(project_topic_suppressions)
)).all()
rulebook_exclusions = (await session.execute(
select(project_rulebook_exclusions)
)).all()
return {
"version": BACKUP_VERSION,
@@ -407,6 +423,7 @@ async def export_full_backup() -> dict:
"rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
"systems": _system_rows(systems),
"record_systems": _record_system_rows(record_systems),
"design_systems": _design_system_rows(design_systems),
@@ -532,8 +549,13 @@ async def export_user_backup(user_id: int) -> dict:
project_topic_suppressions.c.project_id.in_(project_ids)
)
)).all()
rulebook_exclusions = (await session.execute(
select(project_rulebook_exclusions).where(
project_rulebook_exclusions.c.project_id.in_(project_ids)
)
)).all()
else:
subscriptions = rule_suppressions = topic_suppressions = []
subscriptions = rule_suppressions = topic_suppressions = rulebook_exclusions = []
return {
"version": BACKUP_VERSION,
@@ -560,6 +582,7 @@ async def export_user_backup(user_id: int) -> dict:
"rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
"systems": _system_rows(systems),
"record_systems": _record_system_rows(record_systems),
"design_systems": _design_system_rows(design_systems),
@@ -670,7 +693,7 @@ async def _restore_v2(data: dict) -> dict:
"task_logs": 0, "note_drafts": 0, "note_versions": 0,
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
"rulebook_subscriptions": 0, "rule_suppressions": 0,
"topic_suppressions": 0,
"topic_suppressions": 0, "rulebook_exclusions": 0,
"systems": 0, "record_systems": 0, "design_systems": 0,
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
@@ -933,6 +956,17 @@ async def _restore_v2(data: dict) -> dict:
))
stats["topic_suppressions"] += 1
# 14b. Always-on rulebook exclusions (v10, milestone 297)
for exc in data.get("rulebook_exclusions", []):
mapped_pid = project_id_map.get(exc.get("project_id", 0))
mapped_rbid = rulebook_id_map.get(exc.get("rulebook_id", 0))
if mapped_pid is None or mapped_rbid is None:
continue
await session.execute(project_rulebook_exclusions.insert().values(
project_id=mapped_pid, rulebook_id=mapped_rbid,
))
stats["rulebook_exclusions"] += 1
# --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4
# payload restores without them rather than failing on an absent key.
@@ -1137,6 +1171,35 @@ async def _restore_v2(data: dict) -> dict:
))
stats["code_shape_uses"] += 1
# v10: a project's design-system pointer and its inception record ride
# the project but point at design systems and rulebooks restored AFTER
# it — so they are written last, with ids re-mapped. An id that did
# not survive drops out of the record rather than dangling.
for p_data in data.get("projects", []):
new_pid = project_id_map.get(p_data.get("id") or 0)
if new_pid is None:
continue
proj = await session.get(Project, new_pid)
if proj is None:
continue
old_ds = p_data.get("design_system_id")
if old_ds:
proj.design_system_id = design_system_id_map.get(old_ds)
inception = p_data.get("inception")
if isinstance(inception, dict):
choices = dict(inception.get("choices") or {})
choices["exclude_always_on_rulebooks"] = [
rulebook_id_map[i] for i in choices.get("exclude_always_on_rulebooks") or []
if i in rulebook_id_map
]
choices["subscribe_rulebooks"] = [
rulebook_id_map[i] for i in choices.get("subscribe_rulebooks") or []
if i in rulebook_id_map
]
ds = choices.get("design_system_id")
choices["design_system_id"] = design_system_id_map.get(ds) if ds else None
proj.inception = {**inception, "choices": choices}
await session.commit()
logger.info("Restored v2/v3 backup: %s", stats)
+270 -29
View File
@@ -123,6 +123,12 @@ def _definition_on(raw: str) -> tuple[str, str] | None:
name = m.group(1)
if name.startswith("__") and name.endswith("__"):
return None
# `type` announces a definition only when something is declared after
# the name (`type Foo = …`, `type Foo struct {`); an import specifier
# (`import { type Foo, bar }`) is the same two words and defines
# nothing — it showed up as a two-file "identical body" family (#2904).
if line.startswith("type") and not re.search(r"[={]", line[m.end():]):
return None
return ("sym", name)
if m := _ARROW_RE.match(line):
return ("sym", m.group(1))
@@ -156,6 +162,12 @@ def _block_sha(lines: list[str]) -> str:
return hashlib.sha1("\n".join(kept).encode("utf-8")).hexdigest()[:16]
def _declaration_count(lines: list[str]) -> int:
"""How many `prop: value` declarations a CSS block body carries."""
body = " ".join(lines)
return sum(1 for part in body.replace("}", "").split(";") if ":" in part)
def extract_definitions(text: str) -> list[Definition]:
"""Every definition this text makes, with signature + fingerprint.
@@ -186,11 +198,13 @@ def extract_definitions(text: str) -> list[Definition]:
break
block = lines[i:end]
# A CSS rule's fingerprint is its DECLARATIONS, not its selector
# (#2872): the row's identity already carries the selector, and the
# question the fingerprint answers for derive grouping is "is this the
# same rule under another name?" — .closed-msg / .error-block /
# .success-msg with identical bodies are one dup group, not three
# lonely rows. Sym blocks keep their signature line in the hash.
# (#2872): the row's identity already carries the selector. Since
# note 2917 the derive grouping no longer reads CSS bodies at all (a
# class is grouped by name only), so for CSS the fingerprint is the
# recheck identity — "did this rule's body change since it was
# judged?" — and nothing more. The shape of the hash is kept as-is on
# purpose: changing it would flip every judged CSS row to recheck on
# the next sync. Sym blocks keep their signature line in the hash.
if kind == "css":
# One-line rules (`.x { color: red; }`) carry their declarations on
# the selector line itself; a block that is only the selector plus
@@ -202,6 +216,14 @@ def extract_definitions(text: str) -> list[Definition]:
hashed = head + block[1:]
if not any(x.strip() for x in hashed):
hashed = block
# A SINGLE declaration is not a shape (#2903): `color: var(--fs-
# text-tertiary)` under .text-muted, .task-mark and .pin-badge-auto
# is three meanings sharing one line, not three copies of one
# rule. Keep the selector in the hash for one-liners; two
# declarations and up stay selector-agnostic. (Moot for grouping
# since note 2917, kept for fingerprint stability — see above.)
elif _declaration_count(hashed) < 2:
hashed = block
else:
hashed = block
out.append(Definition(
@@ -247,6 +269,142 @@ def scoped_definitions(path: str, text: str, defs: list[Definition]) -> set[tupl
return out
# --- template class references: the CSS consumer map (milestone 302) ---------
# Files whose MARKUP can consume a class. Styling consumers are templates —
# `querySelector('.x')` / classList in scripts are deliberately not read in
# v1 (note 2917: watch CSS by name, by recipe, by token and by what uses it;
# "what uses it" is the template).
_TEMPLATE_SUFFIXES = (
".vue", ".html", ".htm", ".jsx", ".tsx", ".js", ".ts", ".svelte", ".astro",
)
_CLASS_TOKEN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$")
# Static: class="a b" / class='a b' / className="a b". The lookbehind keeps
# `:class=`, `v-bind:class=`, `data-class=` and `headerClass=` out of the
# static form (the Vue/React dynamic forms are read below; the others are
# not class attributes).
_STATIC_CLASS_RE = re.compile(
r"""(?<![:\w.-])(?:class|className)\s*=\s*(?:"([^"]*)"|'([^']*)')"""
)
# Dynamic: Vue `:class="…"` / `v-bind:class="…"`, React `className={…}` (one
# level of nested braces — an object literal inside the expression).
_DYNAMIC_CLASS_RE = re.compile(
r""":class\s*=\s*(?:"([^"]*)"|'([^']*)')"""
r"""|(?<![:\w.-])className\s*=\s*\{((?:[^{}]|\{[^{}]*\})*)\}"""
)
# Svelte's directive form: class:active={cond}.
_SVELTE_CLASS_RE = re.compile(r"(?<![:\w.-])class:([A-Za-z_][A-Za-z0-9_-]*)\s*=")
# Transition classes are applied by the FRAMEWORK, never written in markup:
# <Transition name="toast"> makes Vue add .toast-enter-active et al at
# runtime, and React's <CSSTransition classNames="fade"> does the same. A
# reader of `class=` attributes alone therefore calls every one of those
# rules unused, which is a false positive no amount of care in the
# stylesheet can avoid (#2970). A dynamic `:name="…"` stays unknowable.
_TRANSITION_NAME_RE = re.compile(
r"""<\s*[Tt]ransition(?:-[Gg]roup|Group)?\b[^>]*?(?<![:\w.-])name\s*=\s*"""
r"""(?:"([^"]*)"|'([^']*)')"""
r"""|(?<![:\w.-])classNames\s*=\s*(?:"([^"]*)"|'([^']*)')"""
)
# The union of what Vue 3, Vue 2 and React CSSTransition generate. Naming a
# class that no rule defines costs nothing — it resolves to no row — so the
# union is safer than guessing the framework from the file.
_TRANSITION_SUFFIXES = (
"-enter", "-enter-from", "-enter-active", "-enter-to", "-enter-done",
"-leave", "-leave-from", "-leave-active", "-leave-to",
"-exit", "-exit-active", "-exit-done",
"-appear", "-appear-from", "-appear-active", "-appear-to", "-appear-done",
"-move",
)
# A name built by concatenation — `status-${s}`, 'pri-' + p, class="c-{{ v }}"
# — leaves its static head behind once the hole is blanked. That head is a
# PREFIX reference, spelled `status-*`: "*" cannot occur in a class token, so
# the marker rides the plain token dict without a schema change. Needs a real
# name before the separator; `a-` or a bare `-` says nothing worth matching.
_PREFIX_MIN_STEM = 2
PREFIX_MARK = "*"
# Inside a dynamic expression: string literals (ternary arms, array items,
# quoted object keys) and the bare keys of object literals.
_STR_LIT_RE = re.compile(r"""'([^'\\]*)'|"([^"\\]*)"|`([^`]*)`""")
_OBJ_SPAN_RE = re.compile(r"\{([^{}]*)\}")
_OBJ_KEY_RE = re.compile(r"(?:^|[{,\s])([A-Za-z_][A-Za-z0-9_-]*)\s*:(?!:)")
_TEMPLATE_HOLE_RE = re.compile(r"\$\{[^}]*\}")
# A server-side / mustache interpolation inside a static value (`{{ cls }}`,
# `{% if %}`): unknowable at read time, contributes no token.
_MUSTACHE_RE = re.compile(r"\{[{%][^}]*[}%]\}")
def _class_tokens(value: str) -> list[str]:
"""The class tokens of a static attribute value: whitespace-split, only
well-formed names. An interpolation (`{{ cls }}`, `${cls}`) is blanked
before the split, so a name built around one leaves its static head —
`status-` from `status-{{ s }}` — which is emitted as the prefix
reference `status-*` rather than as a class nothing is called."""
out: list[str] = []
for t in _MUSTACHE_RE.sub(" ", value).split():
if not _CLASS_TOKEN_RE.match(t):
continue
if t.endswith(("-", "_")):
if len(t.rstrip("-_")) >= _PREFIX_MIN_STEM:
out.append(t + PREFIX_MARK)
continue
out.append(t)
return out
def _dynamic_class_tokens(expr: str) -> list[str]:
"""Class tokens named by a dynamic class expression: every string
literal's tokens (a template literal's static text only — its `${…}`
holes are unknowable) and the bare keys of object literals. Bare
identifiers elsewhere (`cond ? clsA : clsB`) are variables, not names."""
out: list[str] = []
for m in _STR_LIT_RE.finditer(expr):
literal = m.group(1) if m.group(1) is not None else (
m.group(2) if m.group(2) is not None else m.group(3)
)
if m.group(3) is not None:
literal = _TEMPLATE_HOLE_RE.sub(" ", literal)
out.extend(_class_tokens(literal))
for span in _OBJ_SPAN_RE.finditer(expr):
# Quoted keys were read as literals above; bare keys here.
body = _STR_LIT_RE.sub(" ", span.group(1))
out.extend(k for k in _OBJ_KEY_RE.findall(body) if _CLASS_TOKEN_RE.match(k))
return out
def class_references(path: str, text: str) -> dict[str, int]:
"""class token → how many times this file's markup names it. Empty for
files that carry no markup (by suffix). Reads the static `class=` /
`className=` attributes, the Vue and React dynamic forms and Svelte's
`class:x` directive; never a CSS selector (`.x {` is a definition, read
by extract_definitions) and never a script's `querySelector('.x')`.
Two forms name classes without spelling them out, and both are read
(#2970): a transition `name=` stands for every class the framework
generates from it, and a concatenated name contributes the prefix
reference `head-*` — which resolve_consumers matches against every row
whose symbol starts with `head-`."""
if not (path or "").lower().endswith(_TEMPLATE_SUFFIXES):
return {}
counts: dict[str, int] = {}
def bump(tokens: list[str]) -> None:
for t in tokens:
counts[t] = counts.get(t, 0) + 1
for m in _STATIC_CLASS_RE.finditer(text):
bump(_class_tokens(m.group(1) if m.group(1) is not None else m.group(2)))
for m in _DYNAMIC_CLASS_RE.finditer(text):
expr = next((g for g in m.groups() if g is not None), "")
bump(_dynamic_class_tokens(expr))
bump([m.group(1) for m in _SVELTE_CLASS_RE.finditer(text)])
for m in _TRANSITION_NAME_RE.finditer(text):
name = next((g for g in m.groups() if g is not None), "").strip()
if not _CLASS_TOKEN_RE.match(name):
continue
bump([name + suffix for suffix in _TRANSITION_SUFFIXES])
return counts
def extract_shapes(text: str) -> list[tuple[str, str]]:
"""Every (kind, name) this text DEFINES — kind is "css" or "sym".
@@ -285,14 +443,31 @@ def shapes_from_archive(blob: bytes) -> list[tuple[str, str, str]]:
return [(d.path, d.kind, d.name) for d in definitions_from_archive(blob)]
class ArchiveScan(NamedTuple):
"""One walk of a repo tarball: what each file DEFINES (the ledger rows)
and which class names each file's markup REFERENCES (the CSS consumer
map, milestone 302) — read together because the bodies are in hand once."""
definitions: list[ArchiveShape]
references: dict[str, dict[str, int]] # path → class token → count
def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
"""Every definition in a repo tarball, with its fingerprint and body.
"""Every definition in a repo tarball, with its fingerprint and body
the definitions half of scan_archive."""
return scan_archive(blob).definitions
def scan_archive(blob: bytes) -> ArchiveScan:
"""Every definition in a repo tarball, with its fingerprint and body,
plus each template-bearing file's class references.
Forge archives wrap content in a single top-level directory (repo-ref/);
that component is stripped so paths match recorded snippet locations,
which are repo-relative. Non-UTF-8 files are binaries and skipped.
"""
shapes: list[ArchiveShape] = []
references: dict[str, dict[str, int]] = {}
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
for member in tar:
if not member.isfile() or "/" not in member.name:
@@ -316,7 +491,10 @@ def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
)
for d in defs
)
return shapes
refs = class_references(path, text)
if refs:
references[path] = refs
return ArchiveScan(shapes, references)
# --- matching shapes against recorded locations ------------------------------
@@ -428,7 +606,8 @@ async def compute_coverage(
# The binding's own ref when it names one (#2873: a dev-first project
# has its ledger follow dev), else the forge's default branch.
ref = binding.ref or await forge.default_branch(api_repo)
definitions = definitions_from_archive(await forge.archive(api_repo, ref))
scan = scan_archive(await forge.archive(api_repo, ref))
definitions = scan.definitions
# The head commit is provenance sugar on the ledger rows; failing to
# learn it must not fail the sync — the ref names the point well
# enough and the row timestamps carry the when.
@@ -440,6 +619,13 @@ async def compute_coverage(
project_id, key, definitions, seen_marker=marker
)
served.append((key, ref))
# The CSS consumer map (milestone 302) rides the same archive: which
# files' markup names each class. Mechanical and recomputable, so it
# must not be able to fail the refresh either.
try:
await shape_ledger.sync_repo_consumers(project_id, key, scan.references)
except Exception:
logger.warning("consumer map sync failed for %s", key, exc_info=True)
# Propose while the bodies are in hand — the one moment they exist.
# Canonical marking below only touches rows the proposer leaves
# alone (a canon's own location never gets a proposal), so the order
@@ -462,15 +648,20 @@ async def compute_coverage(
await shape_ledger.apply_derive_groups(project_id)
except Exception:
logger.warning("derive-first grouping failed", exc_info=True)
# The button-B pass (#2793): shapes new since the PREVIOUS computation,
# where a canon dominates. The previous computation's stamp is the cache;
# a first seed has none, so it flags nothing (everything is new then).
# "Since the previous computation" — the cache's stamp. A first seed has
# none, so nothing is new then. Read once; two passes use it: the
# button-B flag (#2793) and the derive-new drift count (#2899).
since = None
try:
previous = await get_setting(user_id, f"{_CACHE_KEY_PREFIX}{project_id}")
since = None
if previous:
stamp = (json.loads(previous) or {}).get("computed_at")
since = datetime.fromisoformat(stamp) if stamp else None
except Exception:
logger.warning("previous coverage stamp unreadable", exc_info=True)
# The button-B pass (#2793): shapes new since the PREVIOUS computation,
# where a canon dominates.
try:
await shape_ledger.flag_divergence(project_id, since=since)
except Exception:
logger.warning("divergence pass failed", exc_info=True)
@@ -489,8 +680,25 @@ async def compute_coverage(
agg["accounted"] += row.status != "unclassified"
unclassified = counts.pop("unclassified")
proposals = shape_ledger.proposal_summary(rows)
# The CSS consumer map's readout (milestone 302): which files render each
# css row — on the derive groups (a shared recipe vs a scoped one is a
# count), and the negative space: css rules no template names. "Unused"
# is measured only where the map has evidence of templates at all (one
# edge somewhere); a repo of bare stylesheets is "not measured", not
# "all unused".
css_rows = [r for r in rows if r.kind == "css"]
consumer_paths: dict[int, list[str]] = {}
unused_css = None
try:
edges = await shape_ledger.consumers_of([r.id for r in css_rows])
consumer_paths = {sid: [e.path for e in es] for sid, es in edges.items()}
if consumer_paths:
unused_css = sum(1 for r in css_rows if r.id not in consumer_paths)
except Exception:
logger.warning("consumer map read failed", exc_info=True)
proposals = shape_ledger.proposal_summary(rows, consumer_paths=consumer_paths)
divergence = shape_ledger.divergence_summary(rows)
derive_new = shape_ledger.derive_new_summary(rows, since=since)
return {
"total": len(rows),
"accounted": len(rows) - unclassified,
@@ -501,6 +709,13 @@ async def compute_coverage(
"proposed": proposals["proposed"],
"derive_groups": proposals["derive_groups"],
"top_canon": proposals.get("top_canon"),
# Drift since the previous refresh (#2899): copies that joined a
# duplicate family — what the arrival line names so drift is noticed
# on entering, not found by an audit.
"derive_new": derive_new,
# The consumer map's negative space (milestone 302): live css rules
# no template names — None when the map has no evidence of templates.
"unused_css": unused_css,
"proposer": proposer_stats,
# The divergence readout (#2793): button B where button A is canon,
# and judged shapes whose bodies moved since they were judged.
@@ -648,29 +863,55 @@ def coverage_line(coverage: dict) -> str:
line += f"{breakdown}"
line += f" (estimate{', computed ' + day if day else ''})"
unclassified = coverage.get("unclassified", 0)
# The standing work, built whatever the todo count (#2899). Since the
# scoped bucket (#2869) a ledger can read 100% accounted and still carry
# derive groups, proposals and divergence; gating this block on
# `unclassified > 0` is how 439 derive rows went unmentioned.
standing = []
if coverage.get("proposed"):
standing.append(f"{coverage['proposed']} proposed")
n_groups = len(coverage.get("derive_groups") or [])
if n_groups:
standing.append(f"{n_groups} derive group{'s' if n_groups != 1 else ''}")
# Drift since the previous refresh: copies that joined a family, the
# first one named — the sentence the arrival moment exists to say.
new = coverage.get("derive_new") or {}
if new.get("count"):
n = new["count"]
first_new = (new.get("examples") or [{}])[0]
where = (
f": {first_new['label']} in {first_new['path']}"
if first_new.get("label") and first_new.get("path") else ""
)
standing.append(f"+{n} new cop{'y' if n == 1 else 'ies'} since last refresh{where}")
if coverage.get("divergent"):
standing.append(f"{coverage['divergent']} DIVERGENT")
# The next action, on the line (#2874): the canon with the biggest
# queue to confirm, and the widest body-identical copy to consolidate.
top = coverage.get("top_canon") or {}
if top.get("snippet_id"):
standing.append(f"top canon #{top['snippet_id']} ×{top.get('count', 0)}")
first = (coverage.get("derive_groups") or [{}])[0]
if first.get("label") and first.get("files"):
top_copy = f"top copy {first['label']} ×{first['files']} files"
# A css family says what renders it (milestone 302): the count that
# tells a shared recipe from a scoped convention.
if "consumers" in first:
n_t = (first.get("consumers") or {}).get("count", 0)
top_copy += f" · used by {n_t} template{'s' if n_t != 1 else ''}"
standing.append(top_copy)
if coverage.get("unused_css"):
n_u = coverage["unused_css"]
standing.append(f"{n_u} unused class{'es' if n_u != 1 else ''}")
if unclassified:
line += f"; {unclassified} unclassified"
standing = []
if coverage.get("proposed"):
standing.append(f"{coverage['proposed']} proposed")
n_groups = len(coverage.get("derive_groups") or [])
if n_groups:
standing.append(f"{n_groups} derive group{'s' if n_groups != 1 else ''}")
if coverage.get("divergent"):
standing.append(f"{coverage['divergent']} DIVERGENT")
# The next action, on the line (#2874): the canon with the biggest
# queue to confirm, and the widest body-identical copy to consolidate.
top = coverage.get("top_canon") or {}
if top.get("snippet_id"):
standing.append(f"top canon #{top['snippet_id']} ×{top.get('count', 0)}")
first = (coverage.get("derive_groups") or [{}])[0]
if first.get("label") and first.get("files"):
standing.append(f"top copy {first['label']} ×{first['files']} files")
if standing:
line += f" ({', '.join(standing)})"
gaps = [g["dir"] for g in coverage.get("largest_gaps") or []]
if gaps:
line += ", largest: " + ", ".join(gaps)
elif standing:
line += f"; standing: {', '.join(standing)}"
if coverage.get("recheck"):
line += f"; {coverage['recheck']} judged shape{'s' if coverage['recheck'] != 1 else ''} changed since judged — recheck"
return line
+272
View File
@@ -0,0 +1,272 @@
"""Project inception — what a project was decided to inherit (milestone 297).
A project's inheritance is a decision, not a default. The record lives on
``projects.inception``::
{
"decided_at": "<iso>", "decided_by": <user id> | null,
"via": "mcp" | "ui" | "legacy",
"choices": {
"exclude_always_on_rulebooks": [rulebook ids],
"subscribe_rulebooks": [rulebook ids],
"design_system_id": <id> | null,
"seed_systems": bool
}
}
NULL = undecided → enter_project asks. ``legacy`` is the migration's stamp on
projects that existed before the step did (inherit-all / no design system /
no seed), so the ask fires only for projects created after this shipped.
The shape and its validator are pure; ``decide`` composes the existing
services — always-on exclusions, subscriptions, set_project_design_system,
the standard Systems seed — checks every target BEFORE touching anything,
applies the effects (each idempotent), and writes the record LAST, so a
half-applied decision is re-runnable rather than recorded as done.
``current_defaults`` is what the enter_project ask shows: what binds today
if nobody decides.
"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.project import Project
from scribe.models.rulebook import Rulebook
INCEPTION_VIAS = ("mcp", "ui", "legacy")
CHOICE_KEYS = ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems")
def _is_id_list(value) -> bool:
return isinstance(value, list) and all(
isinstance(v, int) and not isinstance(v, bool) and v > 0 for v in value
)
def validate_inception(choices) -> str | None:
"""The structural error an inception ``choices`` object would earn, or
None. Pure and checked BEFORE any effect is applied: a decision either
applies whole or errors whole (the StrictArgs lesson, #2709).
Accepts the four keys, each optional: two id lists (positive ints, no
duplicates between exclude and subscribe), ``design_system_id`` an int
or None, ``seed_systems`` a bool. Unknown keys are an error — a typo
must not become a silently ignored choice."""
if not isinstance(choices, dict):
return "choices must be an object"
unknown = sorted(set(choices) - set(CHOICE_KEYS))
if unknown:
return f"unknown inception choice(s): {', '.join(unknown)} (one of: {', '.join(CHOICE_KEYS)})"
excl = choices.get("exclude_always_on_rulebooks") or []
subs = choices.get("subscribe_rulebooks") or []
if not _is_id_list(excl):
return "exclude_always_on_rulebooks must be a list of rulebook ids"
if not _is_id_list(subs):
return "subscribe_rulebooks must be a list of rulebook ids"
both = sorted(set(excl) & set(subs))
if both:
return f"rulebook(s) {both} cannot be both excluded and subscribed"
ds = choices.get("design_system_id")
if ds is not None and (isinstance(ds, bool) or not isinstance(ds, int) or ds <= 0):
return "design_system_id must be a positive id or null"
seed = choices.get("seed_systems", False)
if not isinstance(seed, bool):
return "seed_systems must be true or false"
return None
def normalize_choices(choices: dict | None) -> dict:
"""The four keys, always present, in canonical form — what gets stored
and what the UI/agent reads back. Call after validate_inception."""
choices = choices or {}
return {
"exclude_always_on_rulebooks": sorted(set(choices.get("exclude_always_on_rulebooks") or [])),
"subscribe_rulebooks": sorted(set(choices.get("subscribe_rulebooks") or [])),
"design_system_id": choices.get("design_system_id"),
"seed_systems": bool(choices.get("seed_systems", False)),
}
def is_decided(project) -> bool:
"""A project is decided once its inception record exists (any via)."""
return bool(getattr(project, "inception", None))
async def current_defaults(user_id: int, project_id: int) -> dict:
"""What the project inherits if nobody decides — the ask's payload.
{always_on_rulebooks: [{id,title}], other_rulebooks: [{id,title}],
excluded_always_on: [...], subscribed_rulebooks: [...],
design_system_id, design_systems: [{id,title}], systems: <count>}.
Instance-agnostic: an install with no rulebooks / design systems shows
empty lists, and the ask says so rather than inventing a default.
"""
from scribe.services import design_systems as design_systems_svc
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
project = await projects_svc.get_project(user_id, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
async with async_session() as session:
rows = (
await session.execute(
select(Rulebook.id, Rulebook.title, Rulebook.always_on)
.where(Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None))
.order_by(Rulebook.title)
)
).all()
applicable = await rulebooks_svc.get_applicable_rules(project_id, user_id, limit=1)
designs = await design_systems_svc.list_design_systems(user_id)
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
return {
"always_on_rulebooks": [{"id": i, "title": t} for i, t, on in rows if on],
"other_rulebooks": [{"id": i, "title": t} for i, t, on in rows if not on],
"excluded_always_on": applicable.get("excluded_always_on", []),
"subscribed_rulebooks": applicable.get("subscribed_rulebooks", []),
"design_system_id": project.design_system_id,
"design_systems": [{"id": d.id, "title": d.title} for d in designs],
"systems": len(systems),
}
async def _check_targets(user_id: int, choices: dict) -> None:
"""Every id a decision names must be the caller's (or readable) BEFORE any
effect lands — a decision applies whole or errors whole."""
from scribe.services import access
wanted = set(choices["exclude_always_on_rulebooks"]) | set(choices["subscribe_rulebooks"])
if wanted:
async with async_session() as session:
rows = (
await session.execute(
select(Rulebook.id, Rulebook.always_on).where(
Rulebook.id.in_(wanted),
Rulebook.owner_user_id == user_id,
Rulebook.deleted_at.is_(None),
)
)
).all()
found = {rid: on for rid, on in rows}
missing = sorted(wanted - set(found))
if missing:
raise ValueError(f"rulebook(s) {missing} not found (or not yours)")
not_always = sorted(r for r in choices["exclude_always_on_rulebooks"] if not found[r])
if not_always:
raise ValueError(
f"rulebook(s) {not_always} are not always-on — only always-on rulebooks "
"can be excluded; a subscribed rulebook is simply not subscribed"
)
ds = choices["design_system_id"]
if ds is not None and not await access.can_read_design_system(user_id, ds):
raise ValueError(f"design system {ds} not found (or not readable)")
async def decide(
user_id: int,
project_id: int,
*,
choices: dict | None,
via: str,
) -> dict:
"""Record a project's inception decision and apply it (milestone 297).
Owner-only. Validates the choices (pure) and every target (owned /
readable) first; then, each idempotent: exclude the named always-on
rulebooks, subscribe the named rulebooks, point the project at the design
system (None = explicitly none), seed the standard Systems if asked and
the project has none; then write ``projects.inception`` LAST. Re-deciding
is additive for exclusions/subscriptions (nothing is silently dropped —
include/unsubscribe are explicit calls), replaces the design system, and
re-seeds nothing a project already has.
Returns {"inception": <record>, "effects": {excluded, subscribed,
design_system_id, systems_seeded}}.
"""
from scribe.services import design_systems as design_systems_svc
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
if via not in INCEPTION_VIAS or via == "legacy":
raise ValueError("via must be 'mcp' or 'ui' ('legacy' is the migration's stamp)")
error = validate_inception(choices or {})
if error:
raise ValueError(error)
choices = normalize_choices(choices)
project = await projects_svc.get_project(user_id, project_id) # owner-scoped
if project is None:
raise ValueError(f"project {project_id} not found (or not yours)")
await _check_targets(user_id, choices)
for rb in choices["exclude_always_on_rulebooks"]:
await rulebooks_svc.exclude_always_on_rulebook_for_project(project_id, rb, user_id)
for rb in choices["subscribe_rulebooks"]:
await rulebooks_svc.subscribe_project(project_id, rb, user_id)
if not await design_systems_svc.set_project_design_system(
user_id, project_id, choices["design_system_id"]
):
raise ValueError("could not set the design system (no write on the project?)")
seeded = (
await systems_svc.seed_standard_systems(user_id, project_id)
if choices["seed_systems"] else []
)
record = {
"decided_at": datetime.now(timezone.utc).isoformat(),
"decided_by": user_id,
"via": via,
"choices": choices,
}
async with async_session() as session:
row = await session.get(Project, project_id)
row.inception = record
row.updated_at = datetime.now(timezone.utc)
await session.commit()
return {
"inception": record,
"effects": {
"excluded": choices["exclude_always_on_rulebooks"],
"subscribed": choices["subscribe_rulebooks"],
"design_system_id": choices["design_system_id"],
"systems_seeded": [sy.name for sy in seeded],
},
}
async def inception_ask(user_id: int, project_id: int) -> dict:
"""The enter_project ask for an undecided project (milestone 297) — the
sibling of the systems-bootstrap ask (#2683): the project's OWN current
defaults, what to ask the operator, and the exact call that answers it.
Fail-open: a hint must never break the call it rides on."""
try:
defaults = await current_defaults(user_id, project_id)
except Exception:
return {}
always = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["always_on_rulebooks"]) or "none"
others = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["other_rulebooks"]) or "none"
designs = ", ".join(f"{d['title']} (#{d['id']})" for d in defaults["design_systems"]) or "none"
return {
"defaults": defaults,
"ask": (
"This project has no inception decision: nobody has said what it "
f"inherits. Today, by default: always-on rulebooks binding it — {always}; "
f"rulebooks it could subscribe to — {others}; design system — "
f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} "
f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
"once: which always-on rulebooks to EXCLUDE here (default: none), which "
"rulebooks to subscribe, which design system (or none), and whether to seed "
"the standard starter Systems — then record the answers. This ask repeats on "
"every enter_project until a decision is recorded."
),
"call": (
f"decide_project_inception(project_id={project_id}, "
"exclude_always_on_rulebooks=[...], subscribe_rulebooks=[...], "
"design_system_id=<id | -1 for none>, seed_systems=<true|false>)"
),
}
+85 -3
View File
@@ -706,6 +706,7 @@ async def build_write_path_hint(
exclude_sync_ids: list[int] | None = None,
stamp_shapes: list[tuple[str, str]] | None = None,
repo_key: str = "",
exclude_derive: list[str] | None = None,
) -> dict:
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
@@ -765,7 +766,7 @@ async def build_write_path_hint(
"""
cfg = await get_writepath_config(user_id)
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg,
"stamped": [], "divergence": []}
"stamped": [], "divergence": [], "derive": [], "derive_keys": []}
path = (path or "").strip()
if not cfg["enabled"] or not path:
return empty
@@ -935,7 +936,20 @@ async def build_write_path_hint(
)
except Exception:
logger.warning("write-time divergence check failed", exc_info=True)
if not synced and not menu and not stamped and not divergence:
# The in-band DERIVE check (#2900): the ledger's own knowledge of the
# names being written — a duplicate family with no canon, or a canon
# recorded elsewhere. This is the arm the by-name local grep could not
# be: it knows whether the other copies are canon or stray. Keyed per
# session (`exclude_derive`) so a family is named once, not per edit.
derive: list[dict] = []
if stamp_shapes and project_id:
try:
found = await shape_ledger_svc.write_time_derive(project_id, path, stamp_shapes)
skip = set(exclude_derive or [])
derive = [d for d in found if d.get("key") not in skip]
except Exception:
logger.warning("write-time derive check failed", exc_info=True)
if not synced and not menu and not stamped and not divergence and not derive:
return empty
owners = await owner_names_for({
@@ -1003,6 +1017,8 @@ async def build_write_path_hint(
lines.append(_stamp_line(path, stamped))
if divergence:
lines.append(_divergence_line(path, divergence))
if derive:
lines.append(_derive_line(path, derive))
# Split by arm, which is the whole reason this table exists. The place arm
# carries no score and so has no home in retrieval_logs; before #2085 a
@@ -1027,9 +1043,62 @@ async def build_write_path_hint(
"config": cfg,
"stamped": stamped,
"divergence": divergence,
"derive": derive,
"derive_keys": [d["key"] for d in derive],
}
def _derive_line(path: str, derive: list[dict]) -> str:
"""The ledger's word on the names being written (#2900): a duplicate
family to derive, or a canon to reuse — said at the write."""
parts = []
for d in derive:
if d.get("canon"):
c = d["canon"]
parts.append(
f"`{c['label']}` is canon — snippet #{c['snippet_id']} at `{c['path']}`; "
"pull it and reuse, don't redefine"
)
continue
f = d["family"]
files = ", ".join(f"`{x}`" for x in f.get("files") or [])
more = f.get("file_count", 0) - len(f.get("files") or [])
if more > 0:
files += f" +{more} more"
n = f.get("file_count", 0)
if f.get("identical"):
what = f"is a duplicate family with no canon — identical body in {n} other file(s)"
else:
# A name family: the same definition name living in several
# files. CSS is only ever grouped this way (note 2917) — a class
# is a recipe, and the recipe is what gets derived or dismissed.
what = f"is a repeated name with no canon — defined in {n} other file(s)"
# What renders a css family (milestone 302): the consumer count is
# the datum that separates a shared recipe from a scoped convention.
cons = f.get("consumers")
if cons is not None:
n_t = cons.get("count", 0)
used = f"; used by {n_t} template{'s' if n_t != 1 else ''}"
if cons.get("paths"):
used += ": " + ", ".join(f"`{x}`" for x in cons["paths"])
extra = n_t - len(cons["paths"])
if extra > 0:
used += f" +{extra} more"
files += used
# The dismissal reason the family most likely earns: a class name
# reused for different purposes is scoped styling; a code name reused
# across modules is convention plumbing.
dismiss = "scoped-css" if d.get("kind") == "css" else "convention-plumbing"
parts.append(
f"`{f['label']}` {what}: {files}; derive it now: "
"record the canon (create_snippet) and make the copies instances "
"(classify_shapes) — or, if these are convention not copies, "
f"`classify_shapes(..., status=\"exempt\", reason_code=\"{dismiss}\")` "
"dismisses the family — rather than adding another copy"
)
return f"> Shape ledger at `{path}`: " + "; ".join(parts) + "."
def _divergence_line(path: str, divergence: list[dict]) -> str:
"""Button B where button A is canon — named at the write (#2793)."""
parts = [
@@ -1097,7 +1166,14 @@ async def build_session_context(
at _MAX_CHARS with an explicit truncation note so the hook can pass it
through verbatim.
"""
rules = await rulebooks_svc.list_always_on_rules(user_id)
# Inside a project, the always-on set is the project's: an inception
# exclusion (milestone 297) takes a rulebook out of this block, and is
# named below so the departure is visible rather than silent.
rules = await rulebooks_svc.list_always_on_rules(user_id, project_id=project_id)
excluded = (
await rulebooks_svc.excluded_always_on_rulebooks(user_id, project_id)
if project_id else []
)
topic_map = await _topic_titles({r.topic_id for r in rules if r.topic_id})
lines: list[str] = [
@@ -1119,6 +1195,12 @@ async def build_session_context(
heading = topic_map.get(r.topic_id, "ungrouped") if r.topic_id else "ungrouped"
lines.append(f"### {heading}")
lines.append(f"- [{r.id}] {r.title}")
if excluded:
names = ", ".join(f"{e['title']} (#{e['id']})" for e in excluded)
lines += [
"",
f"Excluded for this project by its inception decision (not binding here): {names}.",
]
project_dict: dict | None = None
if project_id:
+208
View File
@@ -18,8 +18,14 @@ from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import case, func, select
from scribe.models import async_session
from scribe.models.base import iso
from scribe.models.note import Note
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
from scribe.models.retrieval_log import RetrievalLog
logger = logging.getLogger(__name__)
@@ -135,3 +141,205 @@ def record_retrieval(
return
_pending.add(task)
task.add_done_callback(_pending.discard)
# --- The read half (#2975) ---------------------------------------------------
# Until this existed, `retrieval_logs` was WRITE-ONLY: rows accrued and the only
# `select()` over them in the whole tree lived in a test. That made #1038's gate
# — "build the reranker once telemetry shows precision is the bottleneck" —
# unsatisfiable by construction, and it is why the one real tuning decision on
# record (the 0.68 write-path threshold, #2223) was reached by hand-probing the
# live instance with eight payloads instead of by reading what was collected.
def _bucket(rows: list) -> dict:
"""A score readout a human can act on, from one aggregate row."""
calls, zero, cleared, p10, p50, p90, lo, hi, avg_n, dur = rows
return {
"calls": int(calls or 0),
# A call that returned nothing is not a low-scoring call — it is a
# different failure (nothing indexed, filter too narrow), and averaging
# it into the score distribution would hide both.
"zero_result_calls": int(zero or 0),
# How often the best hit actually cleared the threshold in force for
# that call. THE precision-adjacent number: a surface that clears its
# bar on almost every call is either well-tuned or too loose, and the
# score spread below says which.
"cleared_threshold": int(cleared or 0),
"top_score": {
"p10": _round(p10), "p50": _round(p50), "p90": _round(p90),
"min": _round(lo), "max": _round(hi),
},
"avg_result_count": _round(avg_n),
"p90_duration_ms": _round(dur, 1),
}
def _round(v, places: int = 4):
return None if v is None else round(float(v), places)
async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
"""What the retrieval telemetry says, per surface, over a window.
Two aggregates side by side, each read from the table built for it NOT a
join. `NoteUsageEvent`'s own docstring is explicit that the two are
complements ("RetrievalLog tunes the threshold, this tunes the corpus") and
that RetrievalLog's JSONB `result_ids` "can't be indexed at" the per-note
grain. So the score distribution comes from `retrieval_logs` on its indexed
columns, and surfaced-vs-pulled comes from `note_usage_events` at the grain
it was built for. Reading each from its own table is both cheaper and more
honest than correlating them through JSONB.
Scoped to one user's own telemetry. There is no sharing model for a
retrieval log it records what THIS user's agent asked for, including the
query text so an owner filter is the whole access rule here rather than a
shortcut around `services/access.py` (P#78 governs shared record kinds).
Never raises: a telemetry readout that can break its caller is worse than
no readout. It does distinguish "no rows" from "the read failed", because
#2663 is exactly the bug where those two looked identical for weeks.
"""
since = datetime.now(timezone.utc) - timedelta(days=max(1, int(days)))
out: dict = {
"window_days": int(days),
"since": iso(since),
"sources": {},
"usage": {},
"read_failed": False,
}
cleared = case(
(
(RetrievalLog.threshold.isnot(None))
& (RetrievalLog.top_score.isnot(None))
& (RetrievalLog.top_score >= RetrievalLog.threshold),
1,
),
else_=0,
)
zero = case((RetrievalLog.result_count == 0, 1), else_=0)
def pct(p: float):
return func.percentile_cont(p).within_group(RetrievalLog.top_score.asc())
try:
async with async_session() as session:
rows = (
await session.execute(
select(
RetrievalLog.source,
func.count().label("calls"),
func.sum(zero).label("zero"),
func.sum(cleared).label("cleared"),
pct(0.1), pct(0.5), pct(0.9),
func.min(RetrievalLog.top_score),
func.max(RetrievalLog.top_score),
func.avg(RetrievalLog.result_count),
func.percentile_cont(0.9).within_group(
RetrievalLog.duration_ms.asc()
),
)
.where(
RetrievalLog.created_at >= since,
RetrievalLog.user_id == user_id,
)
.group_by(RetrievalLog.source)
)
).all()
for row in rows:
out["sources"][row[0]] = _bucket(list(row[1:]))
# The corpus side, at its own grain. `ambient` mirrors
# note_usage.usage_for_notes: an ambient surfacing was not a scored
# CHOICE, so folding it into pull-through would understate it.
# Grouped by RAW source, then classified in Python. The
# alternative — CASE expressions in the GROUP BY — is the shape
# that produced #2663: a second case() renders its own expanding
# bind names, the database sees two different expressions and
# rejects the query, and the broad except swallows it. One CASE is
# provably fine (usage_for_notes does it); two is where it broke.
# `source` has a handful of distinct values, so grouping on it
# directly is cheap and cannot fail that way at all.
urows = (
await session.execute(
select(
NoteUsageEvent.event,
NoteUsageEvent.source,
func.count().label("n"),
)
.where(
NoteUsageEvent.created_at >= since,
NoteUsageEvent.user_id == user_id,
)
.group_by(NoteUsageEvent.event, NoteUsageEvent.source)
)
).all()
# Distinct-note counts need their OWN queries, and this is not
# fussiness: count(distinct note_id) per (event, source) group
# cannot be summed across groups — a note surfaced by two sources
# is one distinct note and would be counted twice. A wrong number
# labelled "distinct" is worse than no number.
from scribe.services.note_usage import AMBIENT_SOURCES as _AMB
distinct_surfaced = (
await session.execute(
select(func.count(func.distinct(NoteUsageEvent.note_id))).where(
NoteUsageEvent.created_at >= since,
NoteUsageEvent.user_id == user_id,
NoteUsageEvent.event == SURFACED,
NoteUsageEvent.source.notin_(_AMB),
)
)
).scalar_one()
distinct_pulled = (
await session.execute(
select(func.count(func.distinct(NoteUsageEvent.note_id))).where(
NoteUsageEvent.created_at >= since,
NoteUsageEvent.user_id == user_id,
NoteUsageEvent.event == PULLED,
)
)
).scalar_one()
except Exception:
logger.warning("retrieval summary read failed", exc_info=True)
out["read_failed"] = True
return out
from scribe.services.note_usage import AMBIENT_SOURCES
usage = {
"surfaced": 0, "ambient": 0,
"pulled": 0, "pulled_by_agent": 0, "pulled_by_human": 0,
"distinct_notes_surfaced": int(distinct_surfaced or 0),
"distinct_notes_pulled": int(distinct_pulled or 0),
}
for event, source, n in urows:
n = int(n)
if event == SURFACED:
if source in AMBIENT_SOURCES:
usage["ambient"] += n
else:
usage["surfaced"] += n
elif event == PULLED:
usage["pulled"] += n
# The mcp_/rest_ split is load-bearing (see NoteUsageEvent's own
# comment, which names #1038 — this readout's whole purpose). "Is
# this record dead weight?" is answered by ANY pull; "was that
# injected line useful to the agent?" only by an AGENT pull. So
# pull-through, which exists to answer the second, counts mcp_*
# only. Both halves are reported so the first question is still
# answerable from the same payload.
if source.startswith("mcp_"):
usage["pulled_by_agent"] += n
else:
usage["pulled_by_human"] += n
# Ranked surfacings in the denominator, agent pulls in the numerator: the
# "surfaced often, opened never" reading is only valid where a scored
# surface CHOSE the record and an agent was the one who declined it.
usage["pull_through"] = (
round(usage["pulled_by_agent"] / usage["surfaced"], 4)
if usage["surfaced"] else None
)
out["usage"] = usage
return out
+105 -6
View File
@@ -394,15 +394,57 @@ async def list_rules(
return rulebook_rules + list(proj_result.scalars().all())
async def list_always_on_rules(user_id: int, limit: int = 100) -> list[Rule]:
def _excluded_rulebook_ids_q(project_id: int):
"""Subquery: the always-on rulebooks this project opted out of at
inception (milestone 297) used by every rule-resolution path so an
exclusion is total, not just cosmetic."""
from scribe.models.rulebook import project_rulebook_exclusions
return select(project_rulebook_exclusions.c.rulebook_id).where(
project_rulebook_exclusions.c.project_id == project_id
)
async def excluded_always_on_rulebooks(user_id: int, project_id: int) -> list[dict]:
"""[{id, title}] of the always-on rulebooks excluded for ``project_id``
(owner-scoped). Empty for an undecided or inherit-all project."""
from scribe.models.rulebook import project_rulebook_exclusions
if not project_id:
return []
async with async_session() as session:
rows = (
await session.execute(
select(Rulebook.id, Rulebook.title)
.join(project_rulebook_exclusions,
project_rulebook_exclusions.c.rulebook_id == Rulebook.id)
.where(
project_rulebook_exclusions.c.project_id == project_id,
Rulebook.owner_user_id == user_id,
Rulebook.deleted_at.is_(None),
)
.order_by(Rulebook.title)
)
).all()
return [{"id": rid, "title": title} for rid, title in rows]
async def list_always_on_rules(
user_id: int, limit: int = 100, project_id: int = 0,
) -> list[Rule]:
"""Return all rules from rulebooks flagged always_on for the user.
Called by the MCP tool of the same name at session start to load the
standing rules that apply regardless of which project (if any) is in
scope. Ordering matches list_rules so results are stable across calls.
``project_id`` (milestone 297): inside a project that excluded specific
always-on rulebooks at inception, those rulebooks' rules are NOT
returned the project decided not to inherit them. 0 = the user-wide
set, which is what a session sees before a project is in scope.
"""
async with async_session() as session:
result = await session.execute(
q = (
select(Rule)
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
@@ -413,10 +455,13 @@ async def list_always_on_rules(user_id: int, limit: int = 100) -> list[Rule]:
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
)
.order_by(
)
if project_id:
q = q.where(Rulebook.id.notin_(_excluded_rulebook_ids_q(project_id)))
result = await session.execute(
q.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
)
.limit(limit)
).limit(limit)
)
return list(result.scalars().all())
@@ -489,6 +534,7 @@ async def delete_rule(rule_id: int, user_id: int) -> None:
# ── Subscriptions + get_applicable_rules ───────────────────────────────
from sqlalchemy import insert, delete as sql_delete
from sqlalchemy.exc import IntegrityError
async def subscribe_project(
@@ -568,6 +614,51 @@ async def unsuppress_rule_for_project(
await session.commit()
async def exclude_always_on_rulebook_for_project(
project_id: int, rulebook_id: int, user_id: int,
) -> None:
"""Opt one project out of a whole ALWAYS-ON rulebook (milestone 297).
Owner-only on both sides; the rulebook must be always_on a subscribed
rulebook is left by unsubscribing, not excluding. Idempotent."""
from scribe.models.rulebook import project_rulebook_exclusions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await _assert_rulebook_owned(session, rulebook_id, user_id)
rb = await session.get(Rulebook, rulebook_id)
if rb is None or not rb.always_on:
raise ValueError(
f"rulebook {rulebook_id} is not always-on — it binds only by "
"subscription; unsubscribe_project_from_rulebook instead"
)
try:
await session.execute(
insert(project_rulebook_exclusions).values(
project_id=project_id, rulebook_id=rulebook_id,
)
)
await session.commit()
except IntegrityError:
await session.rollback() # already excluded — idempotent
async def include_always_on_rulebook_for_project(
project_id: int, rulebook_id: int, user_id: int,
) -> None:
"""Undo exclude_always_on_rulebook_for_project. Idempotent."""
from scribe.models.rulebook import project_rulebook_exclusions
async with async_session() as session:
await _assert_project_owned(session, project_id, user_id)
await session.execute(
sql_delete(project_rulebook_exclusions).where(
project_rulebook_exclusions.c.project_id == project_id,
project_rulebook_exclusions.c.rulebook_id == rulebook_id,
)
)
await session.commit()
async def suppress_topic_for_project(
project_id: int, topic_id: int, user_id: int,
) -> None:
@@ -731,6 +822,9 @@ async def get_applicable_rules(
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
# An inception exclusion is total (milestone 297): a rulebook the
# project opted out of contributes nothing, subscribed or not.
Rulebook.id.notin_(_excluded_rulebook_ids_q(project_id)),
)
.order_by(
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
@@ -778,6 +872,7 @@ async def get_applicable_rules(
"suppressed_topics": suppressed_topics,
"truncated": truncated,
"subscribed_rulebooks": subscribed_rulebooks,
"excluded_always_on": await excluded_always_on_rulebooks(user_id, project_id),
}
@@ -786,9 +881,12 @@ def rules_payload(applicable: dict) -> dict:
Every surface that hands rules to an agent (enter_project, get_project,
get_milestone, get_task for legacy plans, start_planning) carries the
same six keys under the same names so a reader learns them once. One
same seven keys under the same names so a reader learns them once. One
place renames `rules` `applicable_rules` and `truncated`
`applicable_rules_truncated`; the tools merge this into their payloads.
`excluded_always_on` (milestone 297) names the always-on rulebooks this
project decided NOT to inherit, so the departure is visible wherever the
rules are.
"""
return {
"applicable_rules": applicable["rules"],
@@ -797,4 +895,5 @@ def rules_payload(applicable: dict) -> dict:
"project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
"excluded_always_on": applicable.get("excluded_always_on", []),
}
+278 -10
View File
@@ -30,7 +30,9 @@ from typing import Iterable, NamedTuple
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.code_shape import REASON_CODES, CodeShape, CodeShapeEvent, CodeShapeUse
from scribe.models.code_shape import (
REASON_CODES, CodeShape, CodeShapeConsumer, CodeShapeEvent, CodeShapeUse,
)
from scribe.models.base import iso
logger = logging.getLogger(__name__)
@@ -262,6 +264,137 @@ async def uses_of(shape_ids) -> dict[int, list[CodeShapeUse]]:
return out
# --- the CSS consumer map (milestone 302) ------------------------------------
def resolve_consumers(
css_rows: Iterable[tuple[int, str, str]],
references: dict[str, dict[str, int]],
) -> dict[tuple[int, str], int]:
"""{(shape_id, consumer_path): count} — which CSS rows each file's markup
consumes. ``css_rows`` are (id, path, symbol) of the repo's live css rows;
``references`` is scan_archive's path → class token → count.
Resolution (note 2917): a class named in file F resolves to F's OWN row
of that name when F defines it (a scoped rule is consumed by its own
template); otherwise to every other file's row of that name — a shared
sheet, or, when several files define it, all of them: the map says
"ambiguous" by fanning out rather than guessing one.
A token ending in ``PREFIX_MARK`` is a PREFIX reference (#2970) — the
static head of a name the template concatenates, `status-*` from
`` `status-${s}` ``. It stands for every row whose symbol starts with
that head, each resolved by the same own-file-else-fan-out rule. The
template cannot tell us WHICH of them it built, so the map credits all
of them rather than calling live rules unused."""
# Lazy, like the extract_definitions import below: coverage reaches into
# this module during a refresh, so neither may import the other at load.
from scribe.services.coverage import PREFIX_MARK
by_symbol: dict[str, list[tuple[int, str]]] = {}
for sid, path, symbol in css_rows:
by_symbol.setdefault(symbol, []).append((sid, path))
out: dict[tuple[int, str], int] = {}
def credit(rows: list[tuple[int, str]], consumer: str, count: int) -> None:
own = [sid for sid, path in rows if path == consumer]
for sid in own or [sid for sid, _path in rows]:
out[(sid, consumer)] = out.get((sid, consumer), 0) + int(count)
for consumer, tokens in references.items():
for token, count in tokens.items():
if token.endswith(PREFIX_MARK):
head = token[: -len(PREFIX_MARK)]
for symbol, rows in by_symbol.items():
if symbol.startswith(head):
credit(rows, consumer, count)
continue
rows = by_symbol.get(token)
if rows:
credit(rows, consumer, count)
return out
async def sync_repo_consumers(
project_id: int, repo_key: str, references: dict[str, dict[str, int]]
) -> int:
"""Rebuild one repo's consumer edges from its archive's class references:
insert the new, refresh changed counts, delete what the tree no longer
says (a template rewritten, a class renamed, a file gone). Edges hang on
live rows only; a vanished row's edges go with this pass. Returns how
many edges stand afterwards."""
async with async_session() as session:
rows = (
await session.execute(
select(CodeShape.id, CodeShape.path, CodeShape.symbol, CodeShape.vanished_at).where(
CodeShape.project_id == project_id,
CodeShape.repo_key == repo_key,
CodeShape.kind == "css",
)
)
).all()
live = [(r[0], r[1], r[2]) for r in rows if r[3] is None]
all_ids = [r[0] for r in rows]
wanted = resolve_consumers(live, references)
existing = (
await session.execute(
select(CodeShapeConsumer).where(CodeShapeConsumer.shape_id.in_(all_ids))
)
).scalars().all() if all_ids else []
have = {(e.shape_id, e.path): e for e in existing}
for key, edge in have.items():
if key not in wanted:
await session.delete(edge)
elif edge.count != wanted[key]:
edge.count = wanted[key]
for (sid, path), count in wanted.items():
if (sid, path) not in have:
session.add(CodeShapeConsumer(shape_id=sid, path=path, count=count, basis="template"))
await session.commit()
return len(wanted)
async def consumers_of(shape_ids) -> dict[int, list[CodeShapeConsumer]]:
"""{shape_id: [edges]} for a set of rows — the read side of the map,
ordered by path so a readout is stable."""
ids = [int(x) for x in shape_ids if x]
if not ids:
return {}
async with async_session() as session:
edges = (
await session.execute(
select(CodeShapeConsumer).where(CodeShapeConsumer.shape_id.in_(ids))
.order_by(CodeShapeConsumer.shape_id, CodeShapeConsumer.path)
)
).scalars().all()
out: dict[int, list[CodeShapeConsumer]] = {}
for e in edges:
out.setdefault(e.shape_id, []).append(e)
return out
# How many consumer files a readout names before "+N more".
_CONSUMERS_SHOWN = 4
def consumer_summary(paths: Iterable[str]) -> dict:
"""{"count", "paths"} — distinct consumer files, sorted, the first few
named. The one shape every surface uses for "used by N template(s)"."""
files = sorted(set(paths))
return {"count": len(files), "paths": files[:_CONSUMERS_SHOWN]}
async def used_by_map(rows: Iterable[CodeShape]) -> dict[int, dict]:
"""{shape_id: consumer_summary} for every css row given — a row with no
consumer gets {"count": 0, "paths": []}: "no template names it" is a
finding, not an absence."""
css = [r for r in rows if r.kind == "css"]
if not css:
return {}
edges = await consumers_of([r.id for r in css])
return {r.id: consumer_summary(e.path for e in edges.get(r.id, [])) for r in css}
async def mark_canonicals(
project_id: int, recorded: list[tuple[int, str, str]]
) -> None:
@@ -574,7 +707,8 @@ async def list_project_shapes(
suggestion), "derive" (a repeats-with-no-canon group), or one basis
name (symbol/reference/text/signature/semantic). ``flag`` narrows to
the readout's asks (#2793): "divergence" (new where a canon dominates,
`diverges_from` names it) or "recheck" (a judged shape whose body moved).
`diverges_from` names it), "recheck" (a judged shape whose body moved),
or "unused-css" (milestone 302: a css rule no template names).
"""
from sqlalchemy import func, or_
@@ -609,6 +743,13 @@ async def list_project_shapes(
conds.append(CodeShape.diverges_from.isnot(None))
elif flag == "recheck":
conds.append(CodeShape.recheck_at.isnot(None))
elif flag == "unused-css":
# The consumer map's negative space (milestone 302): a live css rule
# no file's markup names. A candidate for deletion, surfaced — never
# deleted — because the map reads templates only (a class built at
# runtime, or used from a script, is invisible to it).
conds.append(CodeShape.kind == "css")
conds.append(~CodeShape.id.in_(select(CodeShapeConsumer.shape_id)))
if uses:
# Consumers of a canon (#2870): rows with a uses edge to it, whatever
# shape they themselves are.
@@ -923,6 +1064,13 @@ async def stamp_write_path_instances(
# recur by convention, not by duplication).
_DERIVE_MIN_DUP = 2
_DERIVE_MIN_NAME = 3
# CSS is never grouped by body (note 2917): classes for different purposes
# share declarations because the style system makes them alike — `.text-muted`
# and `.pin-badge-auto` carrying the same `color: var(--fs-text-tertiary)` are
# two meanings, not two copies. A CSS family is a NAME defined in more than
# one file: that is a recipe living in several places, and two is already
# the signal (a class name is deliberate in a way `setup`/`load` are not).
_DERIVE_MIN_NAME_CSS = 2
# Semantic checks per repo per refresh — an embedding each (local fastembed),
# bounded so a 4,000-row ledger is worked through over refreshes, not in one.
_SEMANTIC_CAP = 150
@@ -1298,15 +1446,16 @@ def derive_groups(
rows: Iterable[tuple[str, str, str, str]]
) -> dict[tuple[str, str, str], str]:
"""The derive-first grouping over (path, kind, symbol, body_sha) rows
that matched no canon: {(path, kind, symbol): group_key}. Identical
bodies in 2 places group as `dup:<sha>`; the same name defined in 3
files groups as `name:<kind>:<symbol>`; a row joins at most one group,
the copy before the name."""
that matched no canon: {(path, kind, symbol): group_key}. For code
(kind `sym`) identical bodies in 2 places group as `dup:<sha>` and the
same name defined in 3 files groups as `name:sym:<symbol>`, the copy
before the name. CSS groups by name only the same class defined in
2 files is `name:css:<symbol>`; its body never groups it (note 2917)."""
by_sha: dict[str, list[tuple[str, str, str]]] = {}
by_name: dict[tuple[str, str], list[tuple[str, str, str]]] = {}
for path, kind, symbol, sha in rows:
key = (path, kind, symbol)
if sha:
if sha and kind != "css":
by_sha.setdefault(sha, []).append(key)
by_name.setdefault((kind, _norm_symbol(symbol)), []).append(key)
out: dict[tuple[str, str, str], str] = {}
@@ -1315,7 +1464,8 @@ def derive_groups(
for key in keys:
out.setdefault(key, f"dup:{sha}")
for (kind, symbol), keys in by_name.items():
if len({k[0] for k in keys}) >= _DERIVE_MIN_NAME:
floor = _DERIVE_MIN_NAME_CSS if kind == "css" else _DERIVE_MIN_NAME
if len({k[0] for k in keys}) >= floor:
for key in keys:
out.setdefault(key, f"name:{kind}:{symbol}")
return out
@@ -1360,13 +1510,21 @@ async def apply_derive_groups(project_id: int) -> int:
return grouped
def proposal_summary(rows: Iterable[CodeShape], *, top: int = 8) -> dict:
def proposal_summary(
rows: Iterable[CodeShape], *, top: int = 8,
consumer_paths: dict[int, list[str]] | None = None,
) -> dict:
"""The readout's view of the proposer's standing: how many canon
proposals await confirmation, and the largest derive-first groups."""
proposals await confirmation, and the largest derive-first groups.
``consumer_paths`` (shape_id files whose markup names it, milestone
302) puts `consumers` on each group the family's distinct consumer
files across its members, the datum that separates a shared recipe
from a scoped convention."""
proposed = 0
by_canon: dict[int, int] = {}
groups: dict[str, dict] = {}
files: dict[str, set[str]] = {}
consumers: dict[str, set[str]] = {}
for row in rows:
if row.status not in _MECHANICAL_TODO:
continue
@@ -1387,8 +1545,14 @@ def proposal_summary(rows: Iterable[CodeShape], *, top: int = 8) -> dict:
files.setdefault(row.proposal_group, set()).add(row.path)
if len(g["paths"]) < 3:
g["paths"].append(row.path)
if consumer_paths is not None and row.kind == "css":
consumers.setdefault(row.proposal_group, set()).update(
consumer_paths.get(row.id) or ()
)
for key, g in groups.items():
g["files"] = len(files[key])
if key in consumers:
g["consumers"] = consumer_summary(consumers[key])
# Body-identical groups first (#2872): the things an audit actually
# consolidated were identical bodies under different names/files; a
# name repeated across modules is usually convention. Within a tier,
@@ -1404,6 +1568,34 @@ def proposal_summary(rows: Iterable[CodeShape], *, top: int = 8) -> dict:
return {"proposed": proposed, "derive_groups": ranked[:top], "top_canon": top_canon}
def derive_new_summary(
rows: Iterable[CodeShape], *, since: datetime | None, top: int = 3
) -> dict:
"""The arrival-moment drift signal (#2899): derive-grouped rows FIRST
SEEN after ``since`` the previous refresh's stamp, the same one
flag_divergence uses. "Since the last refresh, N more copies joined a
duplicate family" is the sentence that makes the derive queue a thing
you notice on entering, not a thing an audit finds. ``since`` None (a
first seed) means nothing is new. Judged rows never count."""
if since is None:
return {"count": 0, "examples": []}
fresh = [
r for r in rows
if r.proposal_basis == "derive" and r.proposal_group
and r.status in _MECHANICAL_TODO and r.vanished_at is None
and r.created_at is not None and r.created_at > since
]
fresh.sort(key=lambda r: r.created_at, reverse=True)
return {
"count": len(fresh),
"examples": [
{"label": ("." if r.kind == "css" else "") + r.symbol,
"path": r.path, "group": r.proposal_group}
for r in fresh[:top]
],
}
async def confirm_proposals(
user_id: int,
project_id: int,
@@ -1566,6 +1758,82 @@ async def write_time_divergence(
return out
# How many other files a family line names before "…" — enough to go look,
# not a wall.
_DERIVE_FILES_SHOWN = 4
async def write_time_derive(
project_id: int, path: str, shapes: list[tuple[str, str]]
) -> list[dict]:
"""The in-band DERIVE check (#2900): for each (kind, name) the hook
named at ``path``, what the ledger already knows about that name
elsewhere in the project
family the name sits in a derive-first group (code: identical body
in N files or the same name in 3; CSS: the same class in
2 files, note 2917): "this is a known family with no canon
derive it now, don't add a copy";
canon a `canonical` row of that name at another path: "this is
canon #N at <path> — reuse, don't redefine".
Only for shapes not yet judged at ``path`` (a judged shape is not
re-litigated at every edit), never for the canon's own file. Returns
[{symbol, kind, key, family?|canon?}] `key` is the dedup token the
hook keeps per session (the group id, or canon:<snippet_id>)."""
wanted = {(k, _norm_symbol(n)): n for k, n in shapes if n}
if not wanted:
return []
async with async_session() as session:
rows = (
await session.execute(
select(CodeShape).where(
CodeShape.project_id == project_id,
CodeShape.vanished_at.is_(None),
CodeShape.symbol.in_({norm for (_k, norm) in wanted}),
)
)
).scalars().all()
out: list[dict] = []
for (kind, norm), name in wanted.items():
same = [r for r in rows if r.kind == kind and _norm_symbol(r.symbol) == norm]
here = next((r for r in same if r.path == path), None)
if here is not None and here.status not in _MECHANICAL_TODO:
continue # judged here (or this IS the canon): nothing to say
others = [r for r in same if r.path != path]
label = ("." if kind == "css" else "") + name
canon = next((r for r in others if r.status == "canonical" and r.snippet_id), None)
if canon is not None:
out.append({"symbol": name, "kind": kind, "key": f"canon:{canon.snippet_id}",
"canon": {"snippet_id": canon.snippet_id, "path": canon.path,
"label": label}})
continue
grouped = [r for r in others if r.proposal_group and r.status in _MECHANICAL_TODO]
if here is not None and here.proposal_group:
grouped = [r for r in grouped if r.proposal_group == here.proposal_group] or grouped
if not grouped:
continue
group = grouped[0].proposal_group
members = [r for r in grouped if r.proposal_group == group]
files = sorted({r.path for r in members})
family = {
"group": group, "label": label,
"identical": not group.startswith("name:"),
"files": files[:_DERIVE_FILES_SHOWN], "file_count": len(files),
"size": len(members) + (1 if here is not None else 0),
}
if kind == "css":
# What renders the family (milestone 302): the members' consumer
# files, the row at `path` included when it already exists.
ids = [r.id for r in members] + ([here.id] if here is not None else [])
edges = await consumers_of(ids)
family["consumers"] = consumer_summary(
e.path for es in edges.values() for e in es
)
out.append({"symbol": name, "kind": kind, "key": group, "family": family})
return out
async def flag_divergence(project_id: int, *, since: datetime | None) -> int:
"""Flag shapes created after ``since`` (the previous refresh) that sit
where a canon dominates and were not proposed as that canon. With no
+48
View File
@@ -987,6 +987,46 @@ async def _refresh_provenance(note, commit_sha: str) -> None:
await notes_svc.update_note(note.user_id, note.id, data=data)
def _verdict_still_vouches(note, fields: dict, fetched_commit_sha: str) -> bool:
"""Does a standing `ok` verdict still speak for this body, at this commit?
Containment (cached code fetched file) is the fast path, and it is right
for a record kept verbatim. It is WRONG for a deliberately annotated one
(#2782): a record whose job is to say why the shape is what it is carries
commentary the source does not, so containment fails forever and the record
reads `diverged` on every pull. That turns the one honest drift signal into
a permanent false positive and annotation is a sanctioned record style,
so this is two deliberate designs colliding, not a malformed record.
The escape hatch is the verdict itself. `verify_snippet` is precisely where
a human or agent already judged this body a faithful rendering of that
source, and `verification.commit_sha` records the repo commit they judged
it at a field whose own docstring (#2688) anticipated this use: "makes
'the REPO moved on since the check' computable, once the forge integration
can compare it against the current head." This is that comparison.
All four conditions, and none is optional:
- the verdict says `ok`;
- it has not EXPIRED `verification_view` recomputes `code_sha` against
the record's current body, so editing the record retires the verdict;
- it was not INVALIDATED by a push touching the location (#2691);
- the file we just fetched is at the very commit the verdict was stamped
at. Any later commit means nobody has judged what is there now.
The last one is what keeps this honest: it vouches for a body against ONE
known commit, never against whatever the source has become since. The
moment the file moves, containment resumes as the authority and the record
reads `diverged` until someone re-verifies which is the correct outcome,
because at that point nobody has looked.
"""
if not fetched_commit_sha:
return False
view = verification_view(note, fields)
if view.get("status") != VERIFY_OK or view.get("needs_attention"):
return False
return view.get("commit_sha") == fetched_commit_sha
async def attach_live_body(note, data: dict) -> None:
"""Decorate a PULL response with forge-checked freshness (#2690).
@@ -1115,6 +1155,14 @@ async def attach_live_body(note, data: dict) -> None:
_refresh_provenance(note, fetched.commit_sha),
site="pull provenance-refresh",
)
elif _verdict_still_vouches(note, fields, fetched.commit_sha or ""):
# Containment failed, but an unexpired `ok` verdict stamped at exactly
# this commit already judged this body a faithful rendering of it —
# the annotated-record case (#2782). Trust the judgment over the
# substring test; `data["verification"]` travels in the same payload,
# so a reader can see the basis rather than take "current" on faith.
data["body_source"] = "forge"
data["body_freshness"] = "current"
else:
data["body_source"] = "cache"
data["body_freshness"] = "diverged"
+35
View File
@@ -18,6 +18,41 @@ from scribe.services import access
logger = logging.getLogger(__name__)
# The standard cross-project vocabulary (#2798): names that mean the same
# thing in every project, so a starter set reads the same everywhere. The
# bootstrap ask (mcp/tools/systems) names them; the inception seed
# (services/inception, milestone 297) mints them. Charters are deliberately
# generic — a project refines them as its own records accrue.
STANDARD_SYSTEMS: tuple[tuple[str, str], ...] = (
("CI & Release", "How the project is verified and shipped: pipelines, runners, image/artifact builds, release tagging and rollback."),
("Auth & Access", "Who may do what: identity, sessions/tokens, permissions and the scoping of every read and write to the right users."),
("Data Model & Storage", "What is stored and how it is shaped: the schema, migrations, serialisation and the services that own a table's lifecycle."),
("API Surface", "The doors into the capability: HTTP routes, tool/RPC surfaces, request parsing, error envelopes and their contracts."),
("UI & Design", "What people see and touch: views, components, client state, and the design tokens/recipes they are built from."),
("Import & Export", "Data crossing the boundary: backups, exports, imports, sync with other systems, file formats."),
("Background Jobs", "Work that runs without a request: schedulers, queues, periodic ticks, retention and maintenance."),
("Observability", "How the system reports on itself: logging, metrics, audit trails, health and diagnostics."),
)
async def seed_standard_systems(user_id: int, project_id: int) -> list[System]:
"""Mint the standard starter set for a project that has NO Systems yet
(milestone 297). Idempotent: a project with any System the vocabulary
already started, standard or not gets nothing; the duplicate gate and
the project's own judgment take it from there. [] without write access."""
if await list_systems(user_id, project_id, include_archived=True):
return []
out: list[System] = []
for index, (name, charter) in enumerate(STANDARD_SYSTEMS):
system = await create_system(
user_id, project_id, name, description=charter, order_index=index,
)
if system is None:
break
out.append(system)
return out
async def create_system(
user_id: int,
project_id: int,
+37
View File
@@ -6,6 +6,7 @@ them; a module imports what it needs with ``from tests.helpers import ...``.
"""
from __future__ import annotations
from contextlib import contextmanager
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
@@ -182,3 +183,39 @@ def design_token_stub(name, value_by_mode, group_name=None, purpose=None,
name=name, value_by_mode=value_by_mode, group_name=group_name,
purpose=purpose, order_index=order_index, supersedes=supersedes or [],
)
@contextmanager
def http_sink(reply: bytes = b'{"context":"","note_ids":[]}'):
"""A throwaway local HTTP listener for hook end-to-end tests: yields
``(port, seen)`` where ``seen`` collects every GET's parsed query string
(one dict per request, in order). Lets the shell be tested end to end
the extraction, the encoding, the URL without a Scribe instance.
Three test modules each carried their own ``_Sink`` handler before #2904
consolidated them here; pass ``reply`` for the body the hook should see.
"""
import http.server
import threading
import urllib.parse
seen: list[dict] = []
class _Sink(http.server.BaseHTTPRequestHandler):
def do_GET(self):
seen.append(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(reply)
def log_message(self, *a):
pass
server = http.server.HTTPServer(("127.0.0.1", 0), _Sink)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
yield server.server_port, seen
finally:
server.shutdown()
server.server_close()
+150
View File
@@ -0,0 +1,150 @@
"""The PostToolUse after-write hook (#2901): code written through Bash — sed,
heredocs, scripts gets the same prior-art / ledger checks as a Write/Edit.
Runs the real shell against a temp git repo and a throwaway HTTP sink, like
the pre-write hook's end-to-end tests. Skips where the hook's tools are
missing; asserts on content where they are present."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.helpers import http_sink
PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
HOOK = PLUGIN / "hooks" / "scribe_after_write.sh"
def _env(tmp_path, url="http://127.0.0.1:9"):
for tool in ("git", "jq", "curl", "bash"):
if shutil.which(tool) is None:
pytest.skip(f"hook runtime tool {tool!r} not installed")
return {"PATH": os.environ["PATH"], "SCRIBE_URL": url, "SCRIBE_TOKEN": "t",
"TMPDIR": str(tmp_path), "HOME": str(tmp_path),
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@x"}
def _repo(tmp_path, env):
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
(repo / "b.py").write_text("def one():\n return 1\n")
(repo / "c.py").write_text("def slug(t):\n return t.lower()\n")
subprocess.run(["git", "add", "."], cwd=repo, check=True, env=env)
subprocess.run(["git", "commit", "-q", "-m", "base"], cwd=repo, check=True, env=env)
return repo
def _run(repo, env, session="s-after-1", tool="Bash"):
out = subprocess.run(
["bash", str(HOOK)],
input=json.dumps({"session_id": session, "cwd": str(repo), "tool_name": tool,
"tool_input": {"command": "cat > x"}, "tool_response": {}}),
capture_output=True, text=True, env=env,
)
assert out.returncode == 0, out.stderr
return out.stdout
SINK_REPLY = b'{"context":"> family named","note_ids":[],"sync_note_ids":[],"derive_keys":["dup:483a"]}'
def test_after_write_names_what_bash_just_wrote_then_stays_quiet_until_the_next_change(tmp_path):
with http_sink(SINK_REPLY) as (port, seen):
env = _env(tmp_path, url=f"http://127.0.0.1:{port}")
repo = _repo(tmp_path, env)
# "A Bash call" wrote an untracked stylesheet and appended to a tracked file.
(repo / "a.css").write_text(".log-empty {\n color: red;\n}\n")
(repo / "b.py").write_text("def one():\n return 1\n\ndef slug(t):\n return t\n")
out = _run(repo, env)
by_path = {q["path"][0]: q for q in seen}
assert set(by_path) == {"a.css", "b.py"} # repo-relative, like the pre hook
assert by_path["a.css"]["shapes"] == ["css:log-empty"]
assert by_path["b.py"]["shapes"] == ["sym:slug"]
# Added lines only for the tracked file — the existing def is not "just written".
assert "def slug" in by_path["b.py"]["code"][0] and "def one" not in by_path["b.py"]["code"][0]
ctx = json.loads(out)["hookSpecificOutput"]
assert ctx["hookEventName"] == "PostToolUse"
assert "> family named" in ctx["additionalContext"]
# The local by-name arm rides along: `slug` already lives in c.py.
assert "`slug` is already defined in 1 other file(s): c.py" in ctx["additionalContext"]
# Derive keys landed on the SHARED channel the pre-write hook reads.
state = tmp_path / "scribe-priorart" / "s-after-1.derive.ids"
assert "dup:483a" in state.read_text().split()
# Nothing changed → one git status, no request, no output.
seen.clear()
assert _run(repo, env) == ""
assert seen == []
# Another change → only that file, and the dedup channel goes back up.
(repo / "a.css").write_text(".log-empty {\n color: red;\n}\n.other {\n margin: 0;\n}\n")
_run(repo, env)
assert [q["path"][0] for q in seen] == ["a.css"]
assert seen[0]["exclude_derive"] == ["dup:483a"]
assert set(seen[0]["shapes"][0].split(",")) == {"css:log-empty", "css:other"}
def test_after_write_is_silent_where_it_has_nothing_to_say(tmp_path):
env = _env(tmp_path)
repo = _repo(tmp_path, env)
# Not a Bash call → nothing (hooks.json matches Bash, the script re-checks).
(repo / "a.css").write_text(".x {\n color: red;\n}\n")
assert _run(repo, env, tool="Write") == ""
# Not a git repo → nothing.
loose = tmp_path / "loose"
loose.mkdir()
(loose / "a.css").write_text(".x {\n color: red;\n}\n")
assert _run(loose, env, session="s-loose") == ""
# A change that defines nothing (prose, a call-site edit) → nothing, even
# with the server unreachable (port 9 refuses): no definitions, no call
# owed, so not even the #2932 outage line. (a.css above is removed first:
# it DOES define a shape, and an unanswered call for it would rightly speak.)
(repo / "a.css").unlink()
(repo / "README.md").write_text("# notes\n")
(repo / "b.py").write_text("def one():\n return one_more()\n")
assert _run(repo, env, session="s-quiet") == ""
def test_after_write_local_arm_works_without_a_server_and_says_the_server_did_not_answer(tmp_path):
"""The local by-name arm needs no instance (#2280). A configured instance
that does not ANSWER (a refused connection stands in for it) is said, once
per outage (#2932) — and the record nudge, which claims "nothing recorded",
is withheld: no answer backs that claim."""
env = _env(tmp_path)
repo = _repo(tmp_path, env)
(repo / "d.py").write_text("def slug(t):\n return t.lower()\n")
out = _run(repo, env, session="s-local")
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
assert "`slug` is already defined in 1 other file(s): c.py" in ctx
assert "Scribe did not answer the prior-art check for `d.py` within 8s" in ctx
assert "UNCHECKED" in ctx
assert "None of those existing copies is recorded" not in ctx
marker = tmp_path / "scribe-priorart" / "s-local.unreached"
assert marker.is_file() and marker.read_text().isdigit()
# Still down a moment later: the local arm speaks, the outage line does not
# repeat (once per outage, not once per write).
(repo / "e.py").write_text("def slug(t):\n return t.upper()\n")
out = _run(repo, env, session="s-local")
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
assert "`slug` is already defined in" in ctx
assert "did not answer" not in ctx
def test_after_write_unconfigured_install_owes_no_call_and_keeps_the_record_nudge(tmp_path):
"""No URL/token → no call was owed, so nothing is "unreached"; the local
arm and the record nudge (#2664) stand on their own, as before."""
env = {k: v for k, v in _env(tmp_path).items() if k not in ("SCRIBE_URL", "SCRIBE_TOKEN")}
repo = _repo(tmp_path, env)
(repo / "d.py").write_text("def slug(t):\n return t.lower()\n")
out = _run(repo, env, session="s-unconf")
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
assert "`slug` is already defined in 1 other file(s): c.py" in ctx
assert "create_snippet" in ctx
assert "did not answer" not in ctx
assert not (tmp_path / "scribe-priorart" / "s-unconf.unreached").exists()
+65
View File
@@ -0,0 +1,65 @@
"""Project inception (milestone 297) — step 1: the record's shape.
The WHY a project inherits what it does lives on projects.inception; the
opt-out of an always-on rulebook is its own association table. Pure
validation is pinned here; the effects are step 3's integration tests.
"""
from scribe.models import Base
from scribe.models.project import Project
from scribe.models.rulebook import project_rulebook_exclusions
from scribe.services.inception import (
CHOICE_KEYS, INCEPTION_VIAS, is_decided, normalize_choices, validate_inception,
)
def test_project_carries_an_inception_record_and_to_dict_shows_it():
assert "inception" in Project.__table__.c
assert Project.__table__.c.inception.nullable # NULL = undecided
p = Project(user_id=1, title="x", inception=None)
assert p.to_dict()["inception"] is None and not is_decided(p)
p.inception = {"via": "mcp", "decided_at": "2026-08-22T00:00:00+00:00", "decided_by": 1,
"choices": normalize_choices({"seed_systems": True})}
assert is_decided(p) and p.to_dict()["inception"]["via"] == "mcp"
assert INCEPTION_VIAS == ("mcp", "ui", "legacy")
def test_exclusions_table_is_the_suppressions_sibling():
t = Base.metadata.tables["project_rulebook_exclusions"]
assert project_rulebook_exclusions is t
assert {c.name for c in t.primary_key.columns} == {"project_id", "rulebook_id"}
fks = {fk.column.table.name: fk.ondelete for c in t.columns for fk in c.foreign_keys}
assert fks == {"projects": "CASCADE", "rulebooks": "CASCADE"}
def test_validate_inception_pins_the_choice_vocabulary():
assert CHOICE_KEYS == ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems")
assert validate_inception({}) is None
assert validate_inception({"exclude_always_on_rulebooks": [1], "subscribe_rulebooks": [2],
"design_system_id": 3, "seed_systems": True}) is None
assert validate_inception({"design_system_id": None}) is None
assert "must be an object" in validate_inception([])
assert "unknown inception choice" in validate_inception({"repo": "x"})
assert "list of rulebook ids" in validate_inception({"exclude_always_on_rulebooks": "1"})
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [0]})
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [True]})
assert "both excluded and subscribed" in validate_inception(
{"exclude_always_on_rulebooks": [1, 2], "subscribe_rulebooks": [2]})
assert "positive id or null" in validate_inception({"design_system_id": 0})
assert "positive id or null" in validate_inception({"design_system_id": True})
assert "true or false" in validate_inception({"seed_systems": "yes"})
def test_normalize_choices_is_canonical_and_complete():
out = normalize_choices({"subscribe_rulebooks": [3, 1, 3], "exclude_always_on_rulebooks": [2]})
assert out == {"exclude_always_on_rulebooks": [2], "subscribe_rulebooks": [1, 3],
"design_system_id": None, "seed_systems": False}
assert normalize_choices(None) == {"exclude_always_on_rulebooks": [], "subscribe_rulebooks": [],
"design_system_id": None, "seed_systems": False}
def test_standard_systems_vocabulary_is_one_list_for_ask_and_seed():
from scribe.mcp.tools.systems import _STANDARD_SYSTEMS
from scribe.services.systems import STANDARD_SYSTEMS
assert _STANDARD_SYSTEMS == tuple(n for n, _ in STANDARD_SYSTEMS)
assert len(STANDARD_SYSTEMS) == 8 and all(charter for _, charter in STANDARD_SYSTEMS)
+66
View File
@@ -0,0 +1,66 @@
"""Milestone 297 step 2 — always-on exclusions reach every rule surface.
The SQL is the integration lane's; here the contracts: rules_payload carries
the seventh key, list_always_on_rules takes project_id, the session-start
block names the excluded rulebooks, and the MCP tools mount.
"""
from unittest.mock import AsyncMock, patch
import pytest
from scribe.services.rulebooks import rules_payload
def test_rules_payload_carries_excluded_always_on_as_the_seventh_key():
out = rules_payload({
"rules": [], "truncated": False, "subscribed_rulebooks": [],
"excluded_always_on": [{"id": 1, "title": "Family"}],
})
assert set(out) == {
"applicable_rules", "applicable_rules_truncated", "subscribed_rulebooks",
"project_rules", "suppressed_rules", "suppressed_topics", "excluded_always_on",
}
assert out["excluded_always_on"] == [{"id": 1, "title": "Family"}]
# An older applicable dict without the key still renders (empty list).
assert rules_payload({"rules": [], "truncated": False, "subscribed_rulebooks": []})["excluded_always_on"] == []
def test_list_always_on_rules_service_and_tool_take_a_project_id():
import inspect
from scribe.mcp.tools import rulebooks as tools
from scribe.services import rulebooks as svc
assert "project_id" in inspect.signature(svc.list_always_on_rules).parameters
assert "project_id" in inspect.signature(tools.list_always_on_rules).parameters
@pytest.mark.asyncio
async def test_session_context_names_the_excluded_always_on_rulebooks():
from types import SimpleNamespace as NS
from scribe.services.plugin_context import build_session_context
rules = [NS(id=1, title="`dev` is home", topic_id=1, statement="x")]
project = NS(id=9, title="Widget", goal="", design_system_id=None)
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
AsyncMock(return_value=rules)) as lao, \
patch("scribe.services.plugin_context.rulebooks_svc.excluded_always_on_rulebooks",
AsyncMock(return_value=[{"id": 5, "title": "Design standards"}])), \
patch("scribe.services.plugin_context._topic_titles", AsyncMock(return_value={1: "git"})), \
patch("scribe.services.plugin_context.projects_svc.get_project", AsyncMock(return_value=project)), \
patch("scribe.services.plugin_context.notes_svc.list_notes", AsyncMock(return_value=([], 0))), \
patch("scribe.services.plugin_context.rulebooks_svc.get_applicable_rules",
AsyncMock(return_value={"rules": [], "truncated": False, "subscribed_rulebooks": [],
"project_rules": [], "suppressed_rules": [],
"suppressed_topics": [], "excluded_always_on": []})):
out = await build_session_context(user_id=7, project_id=9)
# The always-on set was asked FOR THIS PROJECT, and the departure is named.
assert lao.await_args.kwargs.get("project_id") == 9
assert "Excluded for this project by its inception decision" in out["context"]
assert "Design standards (#5)" in out["context"]
def test_exclusion_routes_are_registered():
from scribe.app import create_app
rules = {r.rule for r in create_app().url_map.iter_rules()}
assert "/api/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>" in rules
+111
View File
@@ -0,0 +1,111 @@
"""Real-Postgres integration tests for project inception (milestone 297).
What mocks can't prove: a decision's effects land through the real services
(exclusions filter the always-on set, subscriptions bind, the design system
points, the standard Systems seed once), the record is written last, a bad
target applies nothing.
"""
import pytest
import pytest_asyncio
from scribe.models import async_session
from scribe.models.project import Project
from scribe.models.rulebook import Rulebook
from scribe.services import inception as inception_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
@pytest_asyncio.fixture
async def seeded():
"""Owner, a fresh project, one always-on rulebook (with a rule) and one
ordinary rulebook (with a rule)."""
async with async_session() as s:
owner = await ensure_user(s, "inception_owner")
project = Project(user_id=owner.id, title="Inception target")
s.add(project)
await s.flush()
ids = {"owner": owner.id, "pid": project.id}
await s.commit()
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
other = await rulebooks_svc.create_rulebook(ids["owner"], "Optional practices")
async with async_session() as s:
rb = await s.get(Rulebook, always.id)
rb.always_on = True
await s.commit()
t1 = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
await rulebooks_svc.create_rule(t1.id, ids["owner"], "dev is home", "Work on dev.")
t2 = await rulebooks_svc.create_topic(other.id, ids["owner"], "docs")
await rulebooks_svc.create_rule(t2.id, ids["owner"], "Write the why", "Record reasons.")
ids.update({"always": always.id, "other": other.id})
return ids
@pytest.mark.integration
async def test_decide_applies_every_effect_and_records_last(seeded):
owner, pid = seeded["owner"], seeded["pid"]
# Undecided: the always-on rulebook binds, nothing subscribed, no Systems.
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
defaults = await inception_svc.current_defaults(owner, pid)
assert [r["id"] for r in defaults["always_on_rulebooks"]] == [seeded["always"]]
assert [r["id"] for r in defaults["other_rulebooks"]] == [seeded["other"]]
assert defaults["systems"] == 0 and defaults["design_system_id"] is None
out = await inception_svc.decide(owner, pid, via="mcp", choices={
"exclude_always_on_rulebooks": [seeded["always"]],
"subscribe_rulebooks": [seeded["other"]],
"design_system_id": None,
"seed_systems": True,
})
assert out["effects"]["excluded"] == [seeded["always"]]
assert out["effects"]["subscribed"] == [seeded["other"]]
assert len(out["effects"]["systems_seeded"]) == len(systems_svc.STANDARD_SYSTEMS)
# The exclusion is total: the project's always-on set is empty, the
# departure is named, the subscription binds.
assert await rulebooks_svc.list_always_on_rules(owner, project_id=pid) == []
assert len(await rulebooks_svc.list_always_on_rules(owner)) == 1 # user-wide unchanged
applicable = await rulebooks_svc.get_applicable_rules(pid, owner)
assert [r["title"] for r in applicable["rules"]] == ["Write the why"]
assert [e["id"] for e in applicable["excluded_always_on"]] == [seeded["always"]]
assert [s["id"] for s in applicable["subscribed_rulebooks"]] == [seeded["other"]]
# The record, written last, says why.
async with async_session() as s:
project = await s.get(Project, pid)
assert inception_svc.is_decided(project)
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
assert project.inception["choices"]["exclude_always_on_rulebooks"] == [seeded["always"]]
# Re-deciding with seed again mints nothing twice; include reverses the exclusion.
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
assert again["effects"]["systems_seeded"] == []
assert len(await systems_svc.list_systems(owner, pid)) == len(systems_svc.STANDARD_SYSTEMS)
await rulebooks_svc.include_always_on_rulebook_for_project(pid, seeded["always"], owner)
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
@pytest.mark.integration
async def test_a_bad_decision_applies_nothing(seeded):
owner, pid = seeded["owner"], seeded["pid"]
# Excluding a rulebook that is not always-on is refused BEFORE any effect.
with pytest.raises(ValueError, match="not always-on"):
await inception_svc.decide(owner, pid, via="mcp", choices={
"exclude_always_on_rulebooks": [seeded["other"]], "seed_systems": True,
})
assert await systems_svc.list_systems(owner, pid) == []
with pytest.raises(ValueError, match="not found"):
await inception_svc.decide(owner, pid, via="mcp", choices={"subscribe_rulebooks": [999999]})
with pytest.raises(ValueError, match="legacy"):
await inception_svc.decide(owner, pid, via="legacy", choices={})
async with async_session() as s:
project = await s.get(Project, pid)
assert not inception_svc.is_decided(project)
# An outsider cannot decide someone else's project.
async with async_session() as s:
other = await ensure_user(s, "inception_other")
other_id = other.id
await s.commit()
with pytest.raises(ValueError, match="not found"):
await inception_svc.decide(other_id, pid, via="mcp", choices={})
+202 -2
View File
@@ -588,13 +588,213 @@ async def test_derive_groups_land_on_rows_and_in_the_summary(seeded):
assert summary["derive_groups"][1]["label"] == ".card"
assert summary["derive_groups"][0]["size"] == 2 and summary["derive_groups"][1]["size"] == 3
# One of the css copies gets judged → the group shrinks on the next pass.
# One of the css copies gets judged → the group shrinks on the next pass
# but stays a family: a class in two files is already a recipe living in
# two places (css name floor 2, note 2917). Judge the second and it's gone.
await classify_shapes(owner, pid, [
{"path": "b/z.css", "symbol": "card", "status": "exempt", "reason": "print sheet"},
])
await apply_derive_groups(pid)
rows, _ = await list_project_shapes(owner, pid, proposal="derive")
assert {r.symbol for r in rows} == {"slug"} # 2 files < the name floor
assert {r.symbol for r in rows} == {"slug", "card"}
assert {r.path for r in rows if r.symbol == "card"} == {"b/x.css", "b/y.css"}
await classify_shapes(owner, pid, [
{"path": "b/y.css", "symbol": "card", "status": "exempt", "reason": "print sheet"},
])
await apply_derive_groups(pid)
rows, _ = await list_project_shapes(owner, pid, proposal="derive")
assert {r.symbol for r in rows} == {"slug"} # 1 file < the css name floor
@pytest.mark.integration
async def test_write_time_derive_names_the_family_or_the_canon_for_a_name(seeded):
"""#2900: against real rows — a name in a family → the family (other
files, count; for CSS a NAME family, never a body one note 2917); a
name whose canonical row lives elsewhere that canon; a judged row at
the path, the canon's own file, or an unknown name → silence."""
from scribe.services.shape_ledger import apply_derive_groups, write_time_derive
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
defs = _defs(
("v/A.vue", "css", "log-empty", ".log-empty {", ".log-empty { color: red }"),
("v/B.vue", "css", "log-empty", ".log-empty {", ".log-empty { color: red }"),
("v/C.vue", "css", "log-empty", ".log-empty {", ".log-empty { color: red }"),
("src/factory.py", "sym", "factory", "def factory():", "def factory():\n return 1"),
)
await sync_repo_shapes(pid, REPO, defs, seen_marker="m1")
await classify_shapes(owner, pid, [
{"path": "src/factory.py", "symbol": "factory", "status": "canonical", "snippet_id": sid},
])
assert await apply_derive_groups(pid) >= 3
# A 4th copy about to be written → the family, naming the other files.
out = await write_time_derive(pid, "v/D.vue", [("css", "log-empty"), ("css", "unknown")])
assert len(out) == 1 and out[0]["symbol"] == "log-empty" and out[0]["kind"] == "css"
fam = out[0]["family"]
# Three identical bodies, and still a NAME family: CSS never groups by body.
assert fam["identical"] is False and fam["label"] == ".log-empty"
assert fam["files"] == ["v/A.vue", "v/B.vue", "v/C.vue"] and fam["file_count"] == 3
assert out[0]["key"] == fam["group"] == "name:css:log-empty"
# Editing one existing member still names the OTHER members.
out = await write_time_derive(pid, "v/A.vue", [("css", "log-empty")])
assert out[0]["family"]["files"] == ["v/B.vue", "v/C.vue"] and out[0]["family"]["size"] == 3
# The canon's name elsewhere → the canon; in the canon's own file → silence.
out = await write_time_derive(pid, "src/other.py", [("sym", "factory")])
assert out == [{"symbol": "factory", "kind": "sym", "key": f"canon:{sid}",
"canon": {"snippet_id": sid, "path": "src/factory.py", "label": "factory"}}]
assert await write_time_derive(pid, "src/factory.py", [("sym", "factory")]) == []
# A judged row at the path is not re-litigated.
await classify_shapes(owner, pid, [
{"path": "v/B.vue", "symbol": "log-empty", "status": "exempt", "reason": "print sheet"},
])
assert await write_time_derive(pid, "v/B.vue", [("css", "log-empty")]) == []
@pytest.mark.integration
async def test_derive_new_names_the_copy_that_joined_a_family_since_the_stamp(seeded):
"""#2899: the first sync seeds one `slug`; a later sync adds an identical
copy. Against the stamp between them, derive_new counts ONLY the
newcomer the drift since the last refresh, not the whole family."""
from datetime import datetime, timezone
from scribe.services.shape_ledger import (
apply_derive_groups, derive_new_summary, live_rows,
)
owner, pid = seeded["owner"], seeded["pid"]
first = _defs(
("a/one.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"),
)
await sync_repo_shapes(pid, REPO, first, seen_marker="m1")
stamp = datetime.now(timezone.utc)
second = _defs(
("a/one.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"),
("a/two.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"),
)
await sync_repo_shapes(pid, REPO, second, seen_marker="m2")
assert await apply_derive_groups(pid) == 2
rows = await live_rows(pid)
out = derive_new_summary(rows, since=stamp)
assert out["count"] == 1
assert out["examples"][0]["path"] == "a/two.py"
assert out["examples"][0]["label"] == "slug"
assert out["examples"][0]["group"].startswith("dup:")
assert derive_new_summary(rows, since=None)["count"] == 0
@pytest.mark.integration
async def test_transition_and_prefix_references_clear_the_unused_css_flag(seeded):
"""#2970 end to end: the two class forms a template never spells out —
a transition `name=` and a concatenated name travel from the real
extractor through resolution into the flag, so rules they reach stop
being reported as unused. Only a rule nothing can reach stays listed."""
from scribe.services.coverage import class_references
from scribe.services.shape_ledger import sync_repo_consumers, used_by_map, live_rows
pid, owner = seeded["pid"], seeded["owner"]
defs = _defs(
("assets/anim.css", "css", "toast-enter-active",
".toast-enter-active {", ".toast-enter-active { opacity: 0 }"),
("assets/anim.css", "css", "toast-leave-to",
".toast-leave-to {", ".toast-leave-to { opacity: 0 }"),
("assets/state.css", "css", "status-done",
".status-done {", ".status-done { color: green }"),
("assets/state.css", "css", "status-todo",
".status-todo {", ".status-todo { color: grey }"),
("assets/state.css", "css", "really-dead",
".really-dead {", ".really-dead { color: red }"),
)
await sync_repo_shapes(pid, REPO, defs, seen_marker="t1")
refs = {
# Vue applies the toast-* classes itself; the markup names none of them.
"v/Toast.vue": class_references(
"v/Toast.vue", '<transition-group name="toast"><li /></transition-group>'
),
# The board builds its status class; which one is unknowable.
"v/Board.vue": class_references("v/Board.vue", '<b :class="`status-${s}`" />'),
}
assert "toast-enter-active" in refs["v/Toast.vue"]
assert refs["v/Board.vue"] == {"status-*": 1}
await sync_repo_consumers(pid, REPO, refs)
live = [r for r in await live_rows(pid) if r.kind == "css"]
used = await used_by_map(live)
by = {(r.path, r.symbol): used[r.id]["count"] for r in live}
assert by[("assets/anim.css", "toast-enter-active")] == 1
assert by[("assets/anim.css", "toast-leave-to")] == 1
# the prefix credits BOTH candidates — the template does not say which
assert by[("assets/state.css", "status-done")] == 1
assert by[("assets/state.css", "status-todo")] == 1
assert by[("assets/state.css", "really-dead")] == 0
unused, n = await list_project_shapes(owner, pid, flag="unused-css")
assert n == 1 and [(r.path, r.symbol) for r in unused] == [
("assets/state.css", "really-dead")
]
@pytest.mark.integration
async def test_consumer_map_syncs_edges_from_template_references(seeded):
"""Milestone 302: the consumer edges follow the archive — own-file
resolution for a scoped class, fan-out to the shared sheet for a class a
template does not define, counts refreshed and stale edges removed on
the next sync, and a vanished row's edges gone with it."""
from scribe.services.shape_ledger import consumers_of, live_rows, sync_repo_consumers
pid = seeded["pid"]
defs = _defs(
("v/A.vue", "css", "error-msg", ".error-msg {", ".error-msg { color: red }"),
("v/B.vue", "css", "error-msg", ".error-msg {", ".error-msg { color: blue }"),
("assets/components.css", "css", "btn-primary", ".btn-primary {", ".btn-primary { x: 1 }"),
("assets/orphan.css", "css", "orphan", ".orphan {", ".orphan { y: 2 }"), # no template names it
)
await sync_repo_shapes(pid, REPO, defs, seen_marker="m1")
refs = {
"v/A.vue": {"error-msg": 2, "btn-primary": 1},
"v/B.vue": {"error-msg": 1},
"v/C.vue": {"error-msg": 1, "btn-primary": 4},
}
# A and B consume their OWN error-msg; C defines none, so its use fans
# out to both rows; btn-primary resolves to the shared sheet from A and C.
assert await sync_repo_consumers(pid, REPO, refs) == 6
rows = {(r.path, r.symbol): r.id for r in await live_rows(pid) if r.kind == "css"}
edges = await consumers_of(rows.values())
view = {(p, s): [(e.path, e.count) for e in edges.get(i, [])] for (p, s), i in rows.items()}
assert view[("v/A.vue", "error-msg")] == [("v/A.vue", 2), ("v/C.vue", 1)]
assert view[("v/B.vue", "error-msg")] == [("v/B.vue", 1), ("v/C.vue", 1)]
assert view[("assets/components.css", "btn-primary")] == [("v/A.vue", 1), ("v/C.vue", 4)]
assert view[("assets/orphan.css", "orphan")] == []
# The next tree: C stops using error-msg, A uses btn-primary twice now.
refs2 = {"v/A.vue": {"error-msg": 2, "btn-primary": 2}, "v/B.vue": {"error-msg": 1}}
assert await sync_repo_consumers(pid, REPO, refs2) == 3
edges = await consumers_of(rows.values())
assert [(e.path, e.count) for e in edges[rows[("v/B.vue", "error-msg")]]] == [("v/B.vue", 1)]
assert [(e.path, e.count) for e in edges[rows[("assets/components.css", "btn-primary")]]] == [("v/A.vue", 2)]
# The readout side: used_by per css row, the unused-css flag, and the
# family's consumers on the write-path check.
from scribe.services.shape_ledger import (
apply_derive_groups, used_by_map, write_time_derive,
)
owner = seeded["owner"]
live = [r for r in await live_rows(pid) if r.kind == "css"]
used = await used_by_map(live)
assert used[rows[("v/B.vue", "error-msg")]] == {"count": 1, "paths": ["v/B.vue"]}
assert used[rows[("assets/orphan.css", "orphan")]] == {"count": 0, "paths": []}
unused, n = await list_project_shapes(owner, pid, flag="unused-css")
assert n == 1 and [(r.path, r.symbol) for r in unused] == [("assets/orphan.css", "orphan")]
await apply_derive_groups(pid)
out = await write_time_derive(pid, "v/New.vue", [("css", "error-msg")])
assert out and out[0]["family"]["consumers"] == {"count": 2, "paths": ["v/A.vue", "v/B.vue"]}
# B's rule vanishes from the tree → its edges go with the pass.
await sync_repo_shapes(pid, REPO, [d for d in defs if d[0] != "v/B.vue"], seen_marker="m2")
await sync_repo_consumers(pid, REPO, refs2)
edges = await consumers_of(rows.values())
assert rows[("v/B.vue", "error-msg")] not in edges
# --- #2793: the divergence readout against real rows -------------------------
+114
View File
@@ -0,0 +1,114 @@
"""Cross-family contracts for the `list_*` MCP tools (#2278, shape 4).
Per-tool tests cover what each list tool does. Nothing covered what the FAMILY
owes its callers, which is where the missing-sibling shape hides: a capability
added to one member and not its neighbour changes no return value, so no
behavioural test can see it. Source inspection can.
WHAT THIS DELIBERATELY DOES NOT ASSERT. The 19 `list_*` tools are genuinely
heterogeneous 8 take `project_id`, 6 take `limit`, and six take no arguments
at all (`list_projects`, `list_trash`, `list_rulebooks`, `list_design_systems`,
`list_repo_bindings`, `list_starter_role_groups`). Requiring a common parameter
across them would be inventing a convention the API does not have, which the
DRY process's over-DRY guard (§5) warns against by name: a wrong abstraction is
worse than the duplication. So this file asserts ONE contract, the one that is
a real promise rather than a shape coincidence.
THE CONTRACT: a `limit` without an `offset` is a truncation with no
continuation. The caller is told there are 250 results and handed 50, with no
way to ask for the rest. Both tools that had this were capped over a service
that already accepted an offset `snippets_svc.list_snippets(offset=0)` was
simply not exposed, and `list_processes` passed a hardcoded `offset=0` into
`query_knowledge`. The capability existed one layer down in both cases; only
the door was missing.
As in `test_mcp_auth`, the CANDIDATES are derived and the DECISION is explicit.
Deriving the exemption too would make the contract follow a naming convention,
so any future `list_*` could opt itself out by accident.
"""
import ast
import pathlib
TOOLS_DIR = pathlib.Path(__file__).resolve().parents[1] / "src" / "scribe" / "mcp" / "tools"
# `limit` here caps a RANKED top-N, not a page into a corpus, so there is no
# "rest" to ask for — the 51st most-used tag is not what the caller wanted and
# an offset into that ordering answers no question. Anything added here needs a
# reason of that kind, not "it isn't paged yet".
_DELIBERATELY_UNPAGED = {
"list_tags", # most-used tags by count, over a bounded vocabulary
}
def _list_tools() -> dict[str, set[str]]:
"""{tool name: parameter names} for every `list_*` in the tools package."""
out: dict[str, set[str]] = {}
for path in sorted(TOOLS_DIR.glob("*.py")):
if path.name == "__init__.py":
continue
for node in ast.parse(path.read_text()).body:
if (
isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef))
and node.name.startswith("list_")
):
out[node.name] = {
a.arg for a in node.args.args
} | {a.arg for a in node.args.kwonlyargs}
return out
def test_the_tools_package_is_where_we_think_it_is():
"""If this fails the sweep below is silently checking nothing."""
tools = _list_tools()
assert len(tools) >= 15, f"found only {len(tools)} list tools — did the package move?"
def test_every_capped_list_tool_can_be_paged():
"""A `limit` promises a cap; without an `offset` it also imposes a ceiling."""
tools = _list_tools()
capped = {name for name, args in tools.items() if "limit" in args}
assert capped, "no list tool takes a limit — the sweep is not finding signatures"
unpageable = sorted(
name for name in capped
if "offset" not in tools[name] and name not in _DELIBERATELY_UNPAGED
)
assert not unpageable, (
f"these list tools cap their results with no way to page past the cap: "
f"{unpageable}. Each hands the caller a `total` it cannot reach. Add an "
f"`offset` (check the service first — it usually already takes one), or "
f"add the tool to _DELIBERATELY_UNPAGED with a reason saying why there "
f"is no 'rest' to ask for."
)
def test_the_unpaged_exemptions_still_exist():
"""A stale exemption is an exemption for nothing, and it hides the next
tool that inherits the name. Same reverse check `test_mcp_auth` runs on
its allow-lists."""
tools = _list_tools()
missing = sorted(_DELIBERATELY_UNPAGED - set(tools))
assert not missing, (
f"_DELIBERATELY_UNPAGED names tools that no longer exist: {missing}. "
f"Renamed or deleted — drop them from the set."
)
still_capped = sorted(
name for name in _DELIBERATELY_UNPAGED
if name in tools and "limit" not in tools[name]
)
assert not still_capped, (
f"these are exempted from paging but no longer take a `limit` at all, "
f"so the exemption is moot: {still_capped}."
)
def test_offset_never_appears_without_limit():
"""The inverse, and it is a real bug rather than a style point: an offset
with no cap pages through an unbounded result set, so page 2 of an
ever-growing list silently returns everything after the skip."""
tools = _list_tools()
bad = sorted(
name for name, args in tools.items()
if "offset" in args and "limit" not in args
)
assert not bad, f"these take an offset but no limit: {bad}"
+78
View File
@@ -391,3 +391,81 @@ def test_enter_project_registered_in_register():
register(mcp)
assert "enter_project" in mcp.names
# --- milestone 297: the inception doors ---------------------------------------
@pytest.mark.asyncio
async def test_create_project_without_inception_args_stays_undecided():
p = fake_project(id=5, title="P", inception=None)
with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \
patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock()) as decide:
out = await create_project(title="P")
decide.assert_not_awaited()
assert "inception_hint" in out and "inception_effects" not in out
@pytest.mark.asyncio
async def test_create_project_with_inception_args_decides_via_mcp():
p = fake_project(id=5, title="P", inception=None)
decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}}
with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \
patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide:
out = await create_project(title="P", exclude_always_on_rulebooks=[1], design_system_id=-1, seed_systems=True)
kw = decide.await_args.kwargs
assert decide.await_args.args[1] == 5 and kw["via"] == "mcp"
assert kw["choices"] == {"exclude_always_on_rulebooks": [1], "subscribe_rulebooks": [],
"design_system_id": None, "seed_systems": True}
assert out["inception"]["via"] == "mcp" and "inception_effects" in out
@pytest.mark.asyncio
async def test_decide_project_inception_tool_records_an_inherit_all_decision_when_given_nothing():
from scribe.mcp.tools.projects import decide_project_inception
decided = {"inception": {"via": "mcp"}, "effects": {}}
with patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide:
out = await decide_project_inception(project_id=5)
assert decide.await_args.kwargs["choices"] == {}
assert out["project_id"] == 5 and out["inception"]["via"] == "mcp"
@pytest.mark.asyncio
async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project():
applicable = {"rules": [], "project_rules": [], "truncated": False,
"subscribed_rulebooks": [], "excluded_always_on": []}
ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"}
async def run(project):
with patch("scribe.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=project)), \
patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable)), \
patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=[])), \
patch("scribe.mcp.tools.projects.notes_svc.list_notes", AsyncMock(side_effect=[([], 0), ([], 0)])), \
patch("scribe.mcp.tools.projects.systems_svc.list_systems", AsyncMock(return_value=[])), \
patch("scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask", AsyncMock(return_value=None)), \
patch("scribe.mcp.tools.projects.inception_svc.inception_ask", AsyncMock(return_value=ask)) as asked:
return await enter_project(project_id=5), asked
# Own + undecided → the ask rides along.
out, asked = await run(fake_project(id=5, title="P", user_id=7, inception=None))
assert out["inception"] == ask and asked.await_count == 1
# Decided → absent, and the ask is not even built.
out, asked = await run(fake_project(id=5, title="P", user_id=7, inception={"via": "legacy"}))
assert "inception" not in out and asked.await_count == 0
# Someone else's (shared) project, undecided → not this caller's to decide.
out, asked = await run(fake_project(id=5, title="P", user_id=8, inception=None))
assert "inception" not in out and asked.await_count == 0
def test_inception_routes_and_tool_are_registered():
from scribe.app import create_app
from scribe.mcp.server import build_mcp_server
rules = {r.rule for r in create_app().url_map.iter_rules()}
assert "/api/projects/<int:project_id>/inception" in rules
assert "/api/projects/<int:project_id>/inception/defaults" in rules
mcp = build_mcp_server()
assert mcp._tool_manager.get_tool("decide_project_inception") is not None
tool = mcp._tool_manager.get_tool("create_project")
for name in ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems"):
assert name in tool.parameters.get("properties", {}), name
+4 -1
View File
@@ -169,12 +169,15 @@ def test_register_attaches_all_sixteen_tools():
mcp = FakeMCP()
register(mcp)
assert len(mcp.names) == 22
assert len(mcp.names) == 24 # +exclude/include_always_on_rulebook (milestone 297)
# spot-check a few names
assert "list_rulebooks" in mcp.names
assert "create_rule" in mcp.names
assert "subscribe_project_to_rulebook" in mcp.names
assert "list_always_on_rules" in mcp.names
# milestone 297: a project's opt-out of a whole always-on rulebook
assert "exclude_always_on_rulebook" in mcp.names
assert "include_always_on_rulebook" in mcp.names
assert "create_project_rule" in mcp.names
assert "suppress_rule_for_project" in mcp.names
assert "unsuppress_rule_for_project" in mcp.names
+188 -3
View File
@@ -16,7 +16,10 @@ import pytest
import pytest_asyncio
from scribe.services.coverage import (
ArchiveScan,
class_references,
coverage_line,
scan_archive,
extract_shapes,
largest_gaps,
scannable,
@@ -118,6 +121,119 @@ def test_shapes_from_archive_strips_the_wrapper_and_gates_files():
assert shapes_from_archive(_tarball(TREE)) == TREE_SHAPES
# --- unit: template class references — the CSS consumer map (milestone 302) --
def test_class_references_reads_vue_static_and_dynamic_forms_only():
"""A template's class attributes name the classes it consumes: the static
`class=`, the Vue dynamic object/array/ternary forms (string literals and
bare object keys), never a selector in <style>, a `class Foo` in
<script>, a `querySelector('.x')`, or a look-alike attribute."""
vue = (
"<template>\n"
' <div class="card card--wide" :class="{ active: isOpen, \'is-error\': err }">\n'
' <span :class="[ \'pill\', cond ? \'pill-on\' : \'pill-off\', other ]" />\n'
' <p class="card" v-bind:class="open ? openCls : \'closed\'">{{ t }}</p>\n'
' <i data-class="nope" headerClass="nope2" />\n'
" </div>\n"
"</template>\n"
'<script setup lang="ts">\n'
"class Foo {}\n"
"const el = document.querySelector('.zap')\n"
"</script>\n"
"<style scoped>\n"
".card { color: red; }\n"
".zap { color: blue; }\n"
"</style>\n"
)
assert class_references("a/B.vue", vue) == {
"card": 2, "card--wide": 1, "active": 1, "is-error": 1,
"pill": 1, "pill-on": 1, "pill-off": 1, "closed": 1,
}
def test_class_references_reads_framework_transition_names():
"""A transition `name=` is a class reference: the framework applies
`.toast-enter-active` and friends at runtime, so a stylesheet that
defines them is consumed even though no template spells one out (#2970).
Every spelling of the tag counts; a bound `:name` stays unknowable."""
refs = class_references(
"a/T.vue",
'<transition-group name="toast"><div class="toast-item" /></transition-group>',
)
for suffix in ("-enter-from", "-enter-active", "-leave-to", "-move"):
assert refs["toast" + suffix] == 1, suffix
assert refs["toast-item"] == 1 # the static attribute still counts
assert "peek-enter-active" in class_references("a/T.vue", '<Transition name="peek">')
assert "g-move" in class_references("a/T.vue", '<TransitionGroup name="g">')
# React's CSSTransition names the same idea with a different suffix set
react = class_references("a/T.jsx", '<CSSTransition classNames="fade">')
assert {"fade-enter-active", "fade-exit-active", "fade-exit-done"} <= set(react)
# A bound name is a variable, not a name we can read
assert class_references("a/T.vue", '<Transition :name="dyn">') == {}
def test_class_references_reads_a_concatenated_name_as_a_prefix():
"""`status-${s}` cannot be resolved to one class, but its static head is
real information: it is emitted as the prefix reference `status-*` so
the rows it could have built are not reported unused (#2970). A head too
short to mean anything, or a bare separator, says nothing."""
vue = (
'<div :class="`status-${task.status}`" />'
"<span :class=\"['pri-' + p]\" />"
'<b class="a-" /><u class="-" />'
)
assert class_references("a/P.vue", vue) == {"status-*": 1, "pri-*": 1}
# a server template interpolating into the middle of a name, same reading
assert class_references("t/p.html", '<i class="card-{{ v }}" />') == {"card-*": 1}
# an ordinary name never picks up the marker
assert class_references("a/P.vue", '<div class="page-header" />') == {"page-header": 1}
def test_class_references_reads_react_svelte_and_server_templates():
tsx = (
"export function X({ on }: { on: boolean }) {\n"
' return <button className="btn btn-primary" data-x="y">\n'
" <i className={on ? 'tab tab-on' : 'tab'} />\n"
" <b className={`chip ${on ? 'chip-on' : ''} chip-sm`} />\n"
" <u className={cn({ pill: on, 'pill-off': !on })} />\n"
" </button>\n"
"}\n"
)
# A template literal's static text counts; its `${…}` hole is unknowable
# (chip-on sits inside the hole's own ternary and is NOT claimed).
assert class_references("a/x.tsx", tsx) == {
"btn": 1, "btn-primary": 1, "tab": 2, "tab-on": 1,
"chip": 1, "chip-sm": 1, "pill": 1, "pill-off": 1,
}
assert class_references("a/y.svelte", '<div class:active={on} class="row">') == {
"row": 1, "active": 1,
}
# A server-side interpolation contributes no token; a literal class inside
# a template conditional still does.
html = '<div class="row {{ cls }} col-2 {% if x %}y{% endif %}">'
assert class_references("t/p.html", html) == {"row": 1, "col-2": 1, "y": 1}
# Not a template-bearing file: nothing, however it reads.
assert class_references("a/z.py", 'html = \'<div class="row">\'') == {}
def test_scan_archive_returns_definitions_and_references_from_one_walk():
tree = dict(TREE)
tree["web/Card.vue"] = (
b'<template><div class="btn card">x</div></template>\n'
b"<style scoped>\n.card {\n color: red;\n}\n</style>\n"
)
scan = scan_archive(_tarball(tree))
assert isinstance(scan, ArchiveScan)
assert [(d.path, d.kind, d.name) for d in scan.definitions] == TREE_SHAPES + [
("web/Card.vue", "css", "card"),
]
# Only files whose markup names a class appear; the .py/.css files don't.
assert scan.references == {"web/Card.vue": {"btn": 1, "card": 1}}
assert shapes_from_archive(_tarball(tree)) == [(d.path, d.kind, d.name) for d in scan.definitions]
# --- unit: the covering predicate (lives with the ledger since #2788) --------
@@ -149,6 +265,21 @@ def test_largest_gaps_ranks_by_unclassified_and_drops_clean_dirs():
assert gaps == [{"dir": "src", "unclassified": 2, "total": 3}]
def test_type_import_specifiers_are_not_definitions():
"""#2904: `import { type Foo, bar }` is the same two words as `type Foo =`
and defines nothing; only a `type` line with a declaration after the
name counts (TS alias, Go/Rust type)."""
from scribe.services.coverage import extract_shapes
src = (
'import { type DesignSystem, fetchDesignSystems } from "@/api/designSystems";\n'
'import { type Project } from "./x";\n'
"type Baz = { a: number };\n"
"type Wide<T> = T | null;\n"
"type Point struct {\n\tX int\n}\n"
)
assert extract_shapes(src) == [("sym", "Baz"), ("sym", "Wide"), ("sym", "Point")]
def test_coverage_line_is_evidence_carrying_and_labeled_estimate():
line = coverage_line({
"total": 4573, "accounted": 3100, "unclassified": 1473,
@@ -276,6 +407,8 @@ async def test_coverage_measures_the_tree_exactly_and_caches(seeded):
assert coverage["largest_gaps"] == [
{"dir": "src", "unclassified": 2, "total": 3}
]
# #2899: a first computation has no previous stamp — nothing is "new".
assert coverage["derive_new"] == {"count": 0, "examples": []}
# The walk fed the LEDGER (#2788): every extracted shape has a row, the
# snippet reference locations are mechanically stamped canonical WITH
@@ -482,12 +615,18 @@ def test_extract_definitions_fingerprints_each_block():
d = {x.name: x for x in extract_definitions(css)}
assert d["closed-msg"].body_sha == d["error-block"].body_sha != d["other"].body_sha
# One-line rules hash their own declarations — never the empty string
# (first deploy grouped 68 unrelated one-liners as one copy).
one = ".a { color: red; }\n\n.b { color: red; }\n\n.c { color: blue; }\n\n.d {\n color: red;\n}\n"
# (first deploy grouped 68 unrelated one-liners as one copy) — and a
# SINGLE declaration is not a shape (#2903): it keeps its selector in the
# hash, so `.a { color: red }` groups only with another `.a`, never with
# `.b { color: red }`. Two declarations and up stay selector-agnostic.
one = ".a { color: red; }\n\n.b { color: red; }\n\n.c { color: blue; }\n\n.a {\n color: red;\n}\n"
e = {x.name: x for x in extract_definitions(one)}
import hashlib
assert e["a"].body_sha == e["b"].body_sha != e["c"].body_sha
assert e["a"].body_sha != e["b"].body_sha != e["c"].body_sha
assert e["a"].body_sha != hashlib.sha1(b"").hexdigest()[:16]
two = ".a {\n color: red;\n margin: 0;\n}\n.b {\n color: red;\n margin: 0;\n}\n"
f = {x.name: x for x in extract_definitions(two)}
assert f["a"].body_sha == f["b"].body_sha
def test_coverage_line_names_the_proposers_standing():
@@ -504,6 +643,17 @@ def test_coverage_line_names_the_proposers_standing():
assert "; 90 unclassified (40 proposed, 2 derive groups), largest: src" in line
line = coverage_line({**base, "proposed": 0, "derive_groups": [{"group": "a"}]})
assert "(1 derive group)" in line
# Milestone 302: a css top copy says what renders it; unused classes
# join the standing block only when measured (None = no evidence).
line = coverage_line({**base, "unclassified": 0, "proposed": 0, "derive_groups": [
{"group": "name:css:error-msg", "label": ".error-msg", "files": 6,
"consumers": {"count": 6, "paths": ["a.vue"]}}], "unused_css": 3})
assert "top copy .error-msg ×6 files · used by 6 templates" in line
assert "3 unused classes" in line
line = coverage_line({**base, "unclassified": 0, "proposed": 0, "derive_groups": [
{"group": "name:css:x", "label": ".x", "files": 2,
"consumers": {"count": 1, "paths": ["a.vue"]}}], "unused_css": None})
assert "top copy .x ×2 files · used by 1 template" in line and "unused" not in line
# #2874: the next action on the line — biggest canon queue, widest copy.
line = coverage_line({
**base, "proposed": 40, "top_canon": {"snippet_id": 2844, "count": 78},
@@ -512,6 +662,41 @@ def test_coverage_line_names_the_proposers_standing():
assert "top canon #2844 ×78" in line and "top copy closed-msg (identical body) ×3 files" in line
def test_coverage_line_shows_standing_work_even_with_nothing_unclassified():
"""#2899: since the scoped bucket a ledger can be fully accounted and
still carry derive groups / proposals / divergence the line names
them as `standing:` instead of hiding them behind the todo count, and
names the drift since the previous refresh first-copy-first."""
from scribe.services.coverage import coverage_line
base = {
"total": 4693, "accounted": 4693, "unclassified": 0,
"counts": {"canonical": 37, "instance": 977, "variant": 73, "exempt": 1797, "scoped": 1809},
"computed_at": "2026-08-22T00:00:00+00:00", "largest_gaps": [],
}
quiet = coverage_line(base)
assert "unclassified" not in quiet and "standing" not in quiet
line = coverage_line({
**base,
"derive_groups": [{"group": "dup:abc", "label": "log-empty (identical body)", "files": 4}],
"derive_new": {"count": 2, "examples": [
{"label": ".error-msg", "path": "frontend/src/components/InceptionCard.vue", "group": "dup:9f0"},
{"label": ".error-msg", "path": "frontend/src/components/Other.vue", "group": "dup:9f0"},
]},
"divergent": 1,
})
assert "; standing: 1 derive group, +2 new copies since last refresh: .error-msg in " \
"frontend/src/components/InceptionCard.vue, 1 DIVERGENT, top copy log-empty (identical body) ×4 files" in line
assert "unclassified" not in line
# One copy reads singular; with a todo the block keeps its old place.
one = coverage_line({**base, "derive_new": {"count": 1, "examples": []}})
assert one.endswith("; standing: +1 new copy since last refresh")
todo = coverage_line({**base, "unclassified": 3, "accounted": 4690, "proposed": 2,
"derive_new": {"count": 1, "examples": [{"label": "x", "path": "a.py"}]},
"largest_gaps": [{"dir": "src", "unclassified": 3, "total": 9}]})
assert "; 3 unclassified (2 proposed, +1 new copy since last refresh: x in a.py), largest: src" in todo
def test_coverage_line_names_divergence_and_recheck():
from scribe.services.coverage import coverage_line
+2 -2
View File
@@ -18,7 +18,7 @@ def test_backup_version_is_v8():
point of the test a payload section added without moving the version
produces backups that are structurally different and indistinguishable
by inspection."""
assert backup.BACKUP_VERSION == 9
assert backup.BACKUP_VERSION == 10
def test_not_included_lists_the_known_gaps():
@@ -116,7 +116,7 @@ async def test_export_full_backup_contains_every_declared_section():
"systems", "record_systems", "design_systems",
"design_tokens", "note_usage_events", "repo_bindings",
"note_supersessions", "code_shapes", "code_shape_events",
"code_shape_uses"):
"code_shape_uses", "rulebook_exclusions"):
assert key in out, f"missing export section: {key}"
assert out[key] == []
+9
View File
@@ -4,6 +4,15 @@ import pytest
from tests.helpers import fake_note
@pytest.fixture(autouse=True)
def _no_exclusions():
"""build_session_context asks for the bound project's always-on
exclusions (milestone 297); these tests script the rules only."""
with patch("scribe.services.plugin_context.rulebooks_svc.excluded_always_on_rulebooks",
AsyncMock(return_value=[])):
yield
pytestmark = pytest.mark.usefixtures("_no_supersession")
+120
View File
@@ -102,3 +102,123 @@ async def test_insert_retrieval_log_roundtrip(_dispose_engine):
assert row.created_at is not None # server_default now()
await s.execute(delete(RetrievalLog).where(RetrievalLog.user_id == 990001))
await s.commit()
# ─── the read half: retrieval_summary (integration) ──────────────────────────
# Integration, not mocked, and deliberately so. #2663 is the bug where a
# GROUP BY the database rejected was swallowed by a broad except, so every
# counter read zero in production while the writes landed fine and the mocked
# tests passed. `retrieval_summary` runs a grouped aggregate with
# percentile_cont ... WITHIN GROUP and a two-label CASE — precisely the shape
# that failed then. Only a real Postgres can say it parses.
@pytest.mark.integration
@pytest.mark.asyncio
async def test_retrieval_summary_reads_what_the_writer_wrote(_dispose_engine):
from sqlalchemy import delete
from scribe.models import async_session
from scribe.models.note_usage import NoteUsageEvent
from scribe.models.retrieval_log import RetrievalLog
from scribe.services.retrieval_telemetry import (
_insert_retrieval_log, retrieval_summary,
)
UID = 990002
# Three auto_inject calls at a 0.55 bar: two clear it, one does not.
# Plus one call that returned nothing at all — a different failure from a
# low-scoring one, and the readout must not blend them.
for score in (0.91, 0.72, 0.40):
await _insert_retrieval_log(_build_payload(
user_id=UID, source="auto_inject", query="q", threshold=0.55,
limit=3, project_id=None, is_task=None,
results=[(score, _note(1))], duration_ms=5.0,
))
await _insert_retrieval_log(_build_payload(
user_id=UID, source="auto_inject", query="q", threshold=0.55,
limit=3, project_id=None, is_task=None, results=[], duration_ms=5.0,
))
# A second surface, so the GROUP BY has something to separate.
await _insert_retrieval_log(_build_payload(
user_id=UID, source="mcp_search", query="q", threshold=0.45,
limit=10, project_id=None, is_task=None,
results=[(0.80, _note(2))], duration_ms=11.0,
))
# Corpus side: two ranked surfacings, one ambient, one pull.
async with async_session() as s:
s.add_all([
NoteUsageEvent(user_id=UID, note_id=1, event="surfaced", source="auto_inject"),
NoteUsageEvent(user_id=UID, note_id=2, event="surfaced", source="auto_inject"),
NoteUsageEvent(user_id=UID, note_id=3, event="surfaced", source="enter_project"),
NoteUsageEvent(user_id=UID, note_id=1, event="pulled", source="mcp_get_note"),
])
await s.commit()
try:
out = await retrieval_summary(UID, days=30)
assert out["read_failed"] is False, "the aggregate did not execute"
ai = out["sources"]["auto_inject"]
assert ai["calls"] == 4
assert ai["zero_result_calls"] == 1
assert ai["cleared_threshold"] == 2 # 0.91 and 0.72, not 0.40
# p50 over the three scored calls; the empty one contributes no score.
assert ai["top_score"]["p50"] == pytest.approx(0.72, abs=1e-4)
assert ai["top_score"]["min"] == pytest.approx(0.40, abs=1e-4)
assert ai["top_score"]["max"] == pytest.approx(0.91, abs=1e-4)
assert out["sources"]["mcp_search"]["calls"] == 1
u = out["usage"]
assert u["surfaced"] == 2 and u["ambient"] == 1 and u["pulled"] == 1
assert u["distinct_notes_surfaced"] == 2
# The pull came from `mcp_get_note`, so it counts as an AGENT pull
# and drives pull_through; a human `rest_*` pull would not.
assert u["pulled_by_agent"] == 1 and u["pulled_by_human"] == 0
assert u["pull_through"] == pytest.approx(0.5)
finally:
async with async_session() as s:
await s.execute(delete(RetrievalLog).where(RetrievalLog.user_id == UID))
await s.execute(delete(NoteUsageEvent).where(NoteUsageEvent.user_id == UID))
await s.commit()
@pytest.mark.integration
@pytest.mark.asyncio
async def test_retrieval_summary_is_empty_not_broken_for_a_fresh_install(_dispose_engine):
"""Rule #115: an install with no telemetry gets a coherent zero readout,
and `read_failed` stays False the distinction #2663 says must exist."""
from scribe.services.retrieval_telemetry import retrieval_summary
out = await retrieval_summary(990003, days=30)
assert out["read_failed"] is False
assert out["sources"] == {}
assert out["usage"]["pull_through"] is None # no division by zero
assert out["usage"]["surfaced"] == 0
@pytest.mark.integration
@pytest.mark.asyncio
async def test_retrieval_summary_sees_only_its_own_users_telemetry(_dispose_engine):
"""A retrieval log records what one user's agent asked for, query text
included. The owner filter is the access rule, so it gets a test."""
from sqlalchemy import delete
from scribe.models import async_session
from scribe.models.retrieval_log import RetrievalLog
from scribe.services.retrieval_telemetry import (
_insert_retrieval_log, retrieval_summary,
)
await _insert_retrieval_log(_build_payload(
user_id=990004, source="auto_inject", query="theirs", threshold=0.55,
limit=3, project_id=None, is_task=None, results=[(0.9, _note(1))],
duration_ms=1.0,
))
try:
assert (await retrieval_summary(990005, days=30))["sources"] == {}
assert (await retrieval_summary(990004, days=30))["sources"]["auto_inject"]["calls"] == 1
finally:
async with async_session() as s:
await s.execute(delete(RetrievalLog).where(RetrievalLog.user_id == 990004))
await s.commit()
+9
View File
@@ -8,6 +8,15 @@ import pytest
from tests.helpers import fake_rule, fake_rulebook, fake_topic, make_mock_session
@pytest.fixture(autouse=True)
def _no_exclusions():
"""get_applicable_rules asks for the project's always-on exclusions
(milestone 297) through its own session; these mocked-session tests
script the rule queries only, so the exclusions lookup is stubbed empty."""
with patch("scribe.services.rulebooks.excluded_always_on_rulebooks", AsyncMock(return_value=[])):
yield
@pytest.mark.asyncio
async def test_create_rulebook_stores_to_db():
mock_session = make_mock_session()
+125
View File
@@ -308,6 +308,12 @@ def test_derive_groups_copy_before_name_with_floors():
("d.css", "css", "btn", "s1"), ("e.css", "css", "btn", "s2"), ("f.css", "css", "btn", "s3"),
("g.py", "sym", "main", "s4"), ("h.py", "sym", "main", "s5"), # only 2 files → no name group
("i.py", "sym", "one", "s6"),
# CSS (note 2917): identical bodies under different names are NOT a
# copy — two meanings sharing the style system's look; the same
# class in two files IS a family (the name floor is 2 for css).
("j.css", "css", "muted", "same"), ("k.css", "css", "pin-auto", "same"),
("l.css", "css", "card", "c1"), ("m.css", "css", "card", "c2"),
("n.css", "css", "alone", "c3"),
]
g = derive_groups(rows)
assert g[("a.py", "sym", "helper")] == "dup:sha1" == g[("b.py", "sym", "helper")]
@@ -315,6 +321,42 @@ def test_derive_groups_copy_before_name_with_floors():
assert g[("d.css", "css", "btn")] == "name:css:btn"
assert ("g.py", "sym", "main") not in g
assert ("i.py", "sym", "one") not in g
assert ("j.css", "css", "muted") not in g and ("k.css", "css", "pin-auto") not in g
assert g[("l.css", "css", "card")] == "name:css:card" == g[("m.css", "css", "card")]
assert ("n.css", "css", "alone") not in g
assert not any(v.startswith("dup:") for k, v in g.items() if k[1] == "css")
def test_derive_new_summary_counts_copies_first_seen_since_the_previous_refresh():
"""#2899: the arrival-moment drift signal — derive-grouped rows created
after the previous refresh's stamp, newest first, judged rows and a
first seed (since=None) never count."""
from datetime import datetime, timedelta, timezone
from scribe.models.code_shape import CodeShape
from scribe.services.shape_ledger import derive_new_summary
t0 = datetime(2026, 8, 22, 12, 0, tzinfo=timezone.utc)
def row(path, symbol, at, kind="css", status="scoped", group="dup:abc", basis="derive"):
r = CodeShape(project_id=2, repo_key="r", path=path, symbol=symbol, kind=kind,
status=status, proposal_basis=basis, proposal_group=group)
r.created_at = at
return r
rows = [
row("v/Old.vue", "error-msg", t0 - timedelta(days=3)), # before the stamp
row("v/InceptionCard.vue", "error-msg", t0 + timedelta(hours=1)), # new copy
row("v/Other.vue", "error-msg", t0 + timedelta(hours=2)), # newer copy
row("v/J.vue", "error-msg", t0 + timedelta(hours=3), status="exempt"), # judged: never
row("s/a.py", "load", t0 + timedelta(hours=1), kind="sym", group=None, basis=None), # no family
]
out = derive_new_summary(rows, since=t0)
assert out["count"] == 2
assert [e["path"] for e in out["examples"]] == ["v/Other.vue", "v/InceptionCard.vue"]
assert out["examples"][0] == {"label": ".error-msg", "path": "v/Other.vue", "group": "dup:abc"}
assert derive_new_summary(rows, since=None) == {"count": 0, "examples": []}
assert derive_new_summary(rows, since=t0, top=1)["examples"] == [
{"label": ".error-msg", "path": "v/Other.vue", "group": "dup:abc"}]
def test_proposal_summary_ranks_body_identical_groups_first_and_sees_scoped_rows():
@@ -338,6 +380,17 @@ def test_proposal_summary_ranks_body_identical_groups_first_and_sees_scoped_rows
row("v/J.vue", "closed-msg", "dup:abc", status="exempt"),
]
out = proposal_summary(rows)
# Milestone 302: with consumer paths in hand, each css group says what
# renders it — distinct files across the members; absent otherwise.
assert "consumers" not in out["derive_groups"][0]
for i, r in enumerate(rows): # unsaved rows have no id; give them one
r.id = i + 1
cpaths = {rows[0].id: ["v/0.vue", "v/Z.vue"], rows[1].id: ["v/1.vue"], rows[2].id: ["v/0.vue"]}
with_c = proposal_summary(rows, consumer_paths=cpaths)
badge = next(g for g in with_c["derive_groups"] if g["group"] == "name:css:status-badge")
assert badge["consumers"] == {"count": 3, "paths": ["v/0.vue", "v/1.vue", "v/Z.vue"]}
dup = next(g for g in with_c["derive_groups"] if g["group"] == "dup:abc")
assert dup["consumers"] == {"count": 0, "paths": []}
assert [g["group"] for g in out["derive_groups"]] == ["dup:abc", "dup:def", "name:css:status-badge"]
assert out["derive_groups"][0]["files"] == 3 and out["derive_groups"][0]["size"] == 3
assert out["derive_groups"][0]["label"] == "closed-msg (identical body)"
@@ -391,6 +444,78 @@ def test_compact_row_carries_identity_standing_and_the_proposers_word_only():
assert noisy not in compact
def test_resolve_consumers_prefers_the_own_file_and_fans_out_for_shared_names():
"""Milestone 302: a class named in a template resolves to that file's
OWN row when it defines the class (a scoped rule, consumed by its own
markup); otherwise to every other definition of the name one shared
sheet, or all of several (the map fans out rather than guessing)."""
from scribe.services.shape_ledger import resolve_consumers
css_rows = [
(1, "v/A.vue", "error-msg"), # scoped, defined + used in A
(2, "v/B.vue", "error-msg"), # scoped, defined in B, used in B and C
(3, "assets/components.css", "btn-primary"), # the shared sheet
(4, "assets/a.css", "pill"), (5, "assets/b.css", "pill"), # two shared defs
(6, "assets/c.css", "unused"),
]
refs = {
"v/A.vue": {"error-msg": 2, "btn-primary": 1, "nothing-defined": 1},
"v/B.vue": {"error-msg": 1},
"v/C.vue": {"error-msg": 1, "pill": 3},
}
assert resolve_consumers(css_rows, refs) == {
(1, "v/A.vue"): 2, # own row, not B's
(3, "v/A.vue"): 1, # the shared sheet
(2, "v/B.vue"): 1, # own row
(1, "v/C.vue"): 1, (2, "v/C.vue"): 1, # C defines none → every other definition
(4, "v/C.vue"): 3, (5, "v/C.vue"): 3, # ambiguous: both, not a guess
}
# Unknown tokens and an unreferenced row leave no trace.
assert all(sid != 6 for sid, _ in resolve_consumers(css_rows, refs))
def test_resolve_consumers_credits_every_row_a_prefix_could_have_built():
"""#2970: `status-${s}` names a class the map cannot pin down, so the
prefix reference `status-*` credits every row whose symbol starts with
the head each under the same own-file-else-fan-out rule. Crediting all
of them is the honest reading: the alternative is calling live rules
unused, which is what the flag existed to avoid."""
from scribe.services.shape_ledger import resolve_consumers
css_rows = [
(1, "assets/app.css", "status-done"),
(2, "assets/app.css", "status-todo"),
(3, "v/Board.vue", "status-done"), # a scoped copy of one of them
(4, "assets/app.css", "btn-primary"),
(5, "assets/anim.css", "toast-enter-active"),
]
# A file that defines none of them fans out across every match.
assert resolve_consumers(css_rows, {"v/List.vue": {"status-*": 2}}) == {
(1, "v/List.vue"): 2, (2, "v/List.vue"): 2, (3, "v/List.vue"): 2,
}
# A file that DOES define one keeps the own-file rule, per symbol: its own
# status-done row, and the shared status-todo it does not define.
assert resolve_consumers(css_rows, {"v/Board.vue": {"status-*": 1}}) == {
(3, "v/Board.vue"): 1, (2, "v/Board.vue"): 1,
}
# A prefix that matches nothing is silent, and exact tokens are untouched.
assert resolve_consumers(css_rows, {"v/X.vue": {"zz-*": 1}}) == {}
assert resolve_consumers(css_rows, {"v/X.vue": {"btn-primary": 3}}) == {
(4, "v/X.vue"): 3,
}
# A transition class resolves exactly, like any other name.
assert resolve_consumers(css_rows, {"c/Toast.vue": {"toast-enter-active": 1}}) == {
(5, "c/Toast.vue"): 1,
}
def test_consumer_edges_table_cascades_with_the_shape():
from scribe.models import Base
from scribe.models.code_shape import CONSUMER_BASES, CodeShapeConsumer
assert "code_shape_consumers" in Base.metadata.tables
cols = CodeShapeConsumer.__table__.c
assert next(iter(cols.shape_id.foreign_keys)).ondelete == "CASCADE"
assert CONSUMER_BASES == ("template",)
def test_uses_edges_table_and_validation():
"""#2870: consumption is its own relation — a table that cascades with
both ends, and `uses` on a classification must be a list of ids."""
+86
View File
@@ -272,3 +272,89 @@ async def test_forge_failure_inside_lookup_never_breaks_the_pull():
data = _data()
await svc.attach_live_body(_note(), data)
assert "body_source" not in data
# --- #2782: an annotated record is not a diverged one ------------------------
# Containment is right for a verbatim record and wrong for a deliberately
# annotated one: the commentary that makes the record worth reading is exactly
# what makes `cached in fetched` false, forever. These pin the escape hatch —
# a standing `ok` verdict stamped at the commit we just fetched — and, just as
# importantly, every condition that must switch it back off.
ANNOTATED = "# Membership is the contract — this record says WHY, the source can't.\n" + CODE
def _ok_verdict(code=ANNOTATED, commit=SHA, **extra):
verdict = svc.compose_verification(
status=svc.VERIFY_OK, checked_code_sha=svc.code_sha(code), commit_sha=commit
)
verdict.update(extra)
return verdict
async def _freshness(data, *, file_commit=SHA, content=CODE):
forge = _forge_with(lambda r: _file_response(content, commit_sha=file_commit))
with _patched(forge), patch.object(svc.notes_svc, "update_note", AsyncMock()):
await svc.attach_live_body(_note(), data)
await background.drain()
return data["body_source"], data["body_freshness"]
async def test_annotated_record_with_a_standing_verdict_reads_current():
"""The bug: the record's commentary is absent from the source, so
containment fails and every pull said `diverged`. A verdict that already
judged this body faithful, at this very commit, outranks the substring."""
data = _data(code=ANNOTATED, verification=_ok_verdict())
assert await _freshness(data) == ("forge", "current")
assert data["snippet"]["code"] == ANNOTATED # still never rewritten
async def test_the_verdict_vouches_for_one_commit_only():
"""The guard that keeps the hatch honest. The file has moved past the
commit the verdict was stamped at, so nobody has judged what is there
now containment resumes as the authority and the record reads diverged
until someone re-verifies."""
data = _data(code=ANNOTATED, verification=_ok_verdict(commit="a" * 40))
assert await _freshness(data) == ("cache", "diverged")
async def test_an_expired_verdict_does_not_vouch():
"""The record was edited after the check, so `code_sha` no longer matches
and the verdict describes a body that is not this one."""
data = _data(code=ANNOTATED, verification=_ok_verdict(code="def other(): pass"))
assert await _freshness(data) == ("cache", "diverged")
async def test_a_push_invalidated_verdict_does_not_vouch():
"""A push touched the recorded location since the check (#2691) — the repo
moved under the verdict even though the record didn't."""
data = _data(code=ANNOTATED, verification=_ok_verdict(invalidated_by="c" * 40))
assert await _freshness(data) == ("cache", "diverged")
async def test_only_an_ok_verdict_vouches():
"""A drifted verdict is evidence AGAINST the body, not for it."""
verdict = svc.compose_verification(
status=svc.VERIFY_CHANGED, checked_code_sha=svc.code_sha(ANNOTATED), commit_sha=SHA
)
data = _data(code=ANNOTATED, verification=verdict)
assert await _freshness(data) == ("cache", "diverged")
async def test_a_verbatim_record_still_takes_the_containment_path():
"""No regression: the happy path does not route through the hatch, and an
unverified verbatim record is still confirmed by containment alone."""
data = _data(code=CODE)
assert await _freshness(data) == ("forge", "current")
async def test_a_verdict_predating_commit_stamping_does_not_vouch():
"""Verdicts recorded before `commit_sha` existed (#2688) carry no commit to
compare, so they cannot tie the body to a known state of the source. They
fall through to containment rather than vouching on age alone."""
verdict = svc.compose_verification(
status=svc.VERIFY_OK, checked_code_sha=svc.code_sha(ANNOTATED)
)
assert "commit_sha" not in verdict
data = _data(code=ANNOTATED, verification=verdict)
assert await _freshness(data) == ("cache", "diverged")
+223 -68
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from tests.helpers import fake_note
from tests.helpers import fake_note, http_sink
PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
HOOK = PLUGIN / "hooks" / "scribe_prior_art.sh"
@@ -853,14 +853,18 @@ def test_hook_exits_silently_when_unconfigured():
def test_hook_skips_prose_and_data_files():
"""No round-trip for a markdown edit — the server would return nothing anyway."""
src = HOOK.read_text()
skip = re.search(r"case \"\$file_path\" in\n(.*?)esac", src, re.S)
assert skip, "expected an extension skip list"
"""No round-trip for a markdown edit — the server would return nothing
anyway. The list lives in the shared library (#2901) and the hook asks it."""
lib = (PLUGIN / "hooks" / "scribe_defs.sh").read_text()
skip = re.search(r"scribe_skip_path\(\) \{\n case \"\$1\" in\n(.*?)esac", lib, re.S)
assert skip, "expected an extension skip list in scribe_defs.sh"
for ext in ("*.md", "*.json", "*.lock", "*.png"):
assert ext in skip.group(1)
# Config formats are deliberately NOT skipped — a workflow file is reusable.
assert "*.yml" not in skip.group(1)
src = HOOK.read_text()
assert 'scribe_skip_path "$file_path" && exit 0' in src
assert '/scribe_defs.sh"' in src # sourced, not copied
def test_plugin_version_bumped_with_the_hook():
@@ -905,29 +909,35 @@ def _hook_runtime_env():
"SCRIBE_URL": "http://127.0.0.1:9", "SCRIBE_TOKEN": "t"}
def test_hook_nudges_recording_when_copies_exist_but_nothing_is_recorded(tmp_path):
"""#2664: the local arm proves duplication; when Scribe has no record of it,
the same context block must ask for create_snippet the one moment the
recording nudge is earned rather than noise. An unreachable server counts
as "nothing recorded": the local finding needed no server, and the nudge
fails open with it (here: a refused connection stands in for the instance)."""
env = _hook_runtime_env()
def _dup_repo(tmp_path, env):
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
(repo / "a.py").write_text("def debounce(fn):\n return fn\n")
# git grep searches the index, so the existing copy must be staged.
subprocess.run(["git", "add", "."], cwd=repo, check=True, env=env)
out = subprocess.run(
["bash", str(HOOK)],
input=json.dumps({
"session_id": "s-nudge", "cwd": str(repo), "tool_name": "Write",
"tool_input": {"file_path": str(repo / "b.py"),
"content": "def debounce(fn):\n return fn\n"},
}),
capture_output=True, text=True, env=env,
)
return repo
def _write_event(repo, session="s-nudge"):
return json.dumps({
"session_id": session, "cwd": str(repo), "tool_name": "Write",
"tool_input": {"file_path": str(repo / "b.py"),
"content": "def debounce(fn):\n return fn\n"},
})
def test_hook_nudges_recording_when_copies_exist_but_nothing_is_recorded(tmp_path):
"""#2664: the local arm proves duplication; when Scribe ANSWERS that it has
no record of it, the same context block must ask for create_snippet the
one moment the recording nudge is earned rather than noise."""
with http_sink(b'{"context":"","note_ids":[],"sync_note_ids":[]}') as (port, seen):
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}")
repo = _dup_repo(tmp_path, env)
out = subprocess.run(["bash", str(HOOK)], input=_write_event(repo),
capture_output=True, text=True, env=env)
assert out.returncode == 0
assert seen and seen[0]["path"] == ["b.py"]
assert out.stdout.strip(), (
"hook produced no output — the local arm should have found the "
"staged duplicate and nudged"
@@ -935,6 +945,51 @@ def test_hook_nudges_recording_when_copies_exist_but_nothing_is_recorded(tmp_pat
ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
assert "already defined" in ctx # the duplication finding
assert "create_snippet" in ctx # the recording ask riding it
assert "did not answer" not in ctx
def test_hook_says_when_scribe_did_not_answer_once_per_outage(tmp_path):
"""#2932: a configured instance that does not answer (refused connection)
is SAID the write went unchecked instead of the hook failing open in
silence; the record nudge's "nothing recorded" claim is withheld. Once per
outage: a second miss is quiet, an answer clears the marker, and the next
miss speaks again. The marker is shared with the after-write hook."""
env = _hook_runtime_env() # SCRIBE_URL → a refused port
repo = _dup_repo(tmp_path, env)
marker = tmp_path / "scribe-priorart" / "s-out.unreached"
env["TMPDIR"] = str(tmp_path)
out = subprocess.run(["bash", str(HOOK)], input=_write_event(repo, "s-out"),
capture_output=True, text=True, env=env)
assert out.returncode == 0
ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
assert "already defined" in ctx
assert "Scribe did not answer the prior-art check for `b.py` within 5s" in ctx
assert "UNCHECKED" in ctx and "list_shapes" in ctx
assert "None of those existing copies is recorded" not in ctx
assert marker.is_file()
# Second miss inside the quiet window: local arm only.
out = subprocess.run(["bash", str(HOOK)], input=_write_event(repo, "s-out"),
capture_output=True, text=True, env=env)
ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
assert "already defined" in ctx and "did not answer" not in ctx
# An answer clears the marker …
with http_sink(b'{"context":"","note_ids":[],"sync_note_ids":[]}') as (port, _seen):
up = dict(env, SCRIBE_URL=f"http://127.0.0.1:{port}")
subprocess.run(["bash", str(HOOK)], input=_write_event(repo, "s-out"),
capture_output=True, text=True, env=up)
assert not marker.exists()
# … so the next outage is announced afresh.
out = subprocess.run(["bash", str(HOOK)], input=_write_event(repo, "s-out"),
capture_output=True, text=True, env=env)
assert "did not answer" in json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
# A write the hook had nothing local to say about still carries the line
# (the line is the whole message then): a fresh session, no duplicate.
(repo / "a.py").unlink()
subprocess.run(["git", "add", "-A"], cwd=repo, check=True, env=env)
out = subprocess.run(["bash", str(HOOK)], input=_write_event(repo, "s-out-2"),
capture_output=True, text=True, env=env)
ctx = json.loads(out.stdout)["hookSpecificOutput"]["additionalContext"]
assert ctx.startswith("> Scribe did not answer")
def test_hook_stays_quiet_about_recording_when_nothing_is_duplicated(tmp_path):
@@ -978,24 +1033,26 @@ def test_local_arm_finds_duplicates_in_every_language_family(
every Go/Kotlin/Rust project, which is exactly where the operator observed
recording never happening. Each case stages an existing copy and writes the
same definition to a second file; the hook must prove the duplication and
ask for the record."""
env = _hook_runtime_env()
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
(repo / fname).write_text(definition)
subprocess.run(["git", "add", "."], cwd=repo, check=True, env=env)
ext = fname.rsplit(".", 1)[1]
out = subprocess.run(
["bash", str(HOOK)],
input=json.dumps({
"session_id": f"s-lang-{ext}", "cwd": str(repo),
"tool_name": "Write",
"tool_input": {"file_path": str(repo / f"copy.{ext}"),
"content": definition},
}),
capture_output=True, text=True, env=env,
)
ask for the record (the instance ANSWERS "nothing recorded" since #2932
an unanswered call withholds the nudge, so a sink stands in for it)."""
with http_sink(b'{"context":"","note_ids":[],"sync_note_ids":[]}') as (port, _seen):
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}")
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
(repo / fname).write_text(definition)
subprocess.run(["git", "add", "."], cwd=repo, check=True, env=env)
ext = fname.rsplit(".", 1)[1]
out = subprocess.run(
["bash", str(HOOK)],
input=json.dumps({
"session_id": f"s-lang-{ext}", "cwd": str(repo),
"tool_name": "Write",
"tool_input": {"file_path": str(repo / f"copy.{ext}"),
"content": definition},
}),
capture_output=True, text=True, env=env,
)
assert out.returncode == 0
assert out.stdout.strip(), (
f"hook produced no output for {fname} — the local arm should have "
@@ -1145,12 +1202,31 @@ def test_route_stamps_only_for_a_caller_allowed_to_write():
assert "&shapes=" in hook
def test_hook_does_not_name_a_type_import_specifier_as_a_shape(tmp_path):
"""#2904, the awk mirror of the server rule: `type Foo,` inside an import
list is not a definition; `type Baz = ` on its own line is."""
env = _hook_runtime_env()
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
seen = _run_hook_against_sink(tmp_path, {
"session_id": "s-type", "cwd": str(repo), "tool_name": "Write",
"tool_input": {"file_path": str(repo / "x.ts"),
"content": 'import { type Foo, bar } from "./y";\n'
"type Baz = { a: number };\n"
"export function use(): Baz {\n return { a: 1 };\n}\n"},
})
assert seen["shapes"] == ["sym:Baz,sym:use"]
def test_hook_names_the_shapes_being_written():
"""The feed's two inputs: every definition in the payload, or — for an Edit
that changes a body, not a signature the definition enclosing the edit,
found by walking the target file upward from the edited lines."""
src = HOOK.read_text()
assert "scribe_defs()" in src # one extractor, two consumers
lib = (PLUGIN / "hooks" / "scribe_defs.sh").read_text()
assert "scribe_defs()" in lib # one extractor, shared (#2901)
assert "scribe_defs()" not in src # ...not a second copy here
assert ".tool_input.old_string" in src # the Edit's anchor
assert "| tac | scribe_defs | head -1" in src # nearest definition above
# The ledger feed sends NAMES, never bodies, and stays on the one GET.
@@ -1159,39 +1235,15 @@ def test_hook_names_the_shapes_being_written():
def _run_hook_against_sink(tmp_path, payload):
"""Run the hook with SCRIBE_URL pointed at a throwaway local listener and
return the query the hook sent. Lets the shell be tested end to end
the extraction, the encoding, the URL without a Scribe instance."""
import http.server
import threading
import urllib.parse
seen: dict = {}
class _Sink(http.server.BaseHTTPRequestHandler):
def do_GET(self):
seen.update(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"context":"","note_ids":[]}')
def log_message(self, *a):
pass
server = http.server.HTTPServer(("127.0.0.1", 0), _Sink)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{server.server_port}")
return the query the hook sent (tests.helpers.http_sink)."""
with http_sink() as (port, seen):
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}")
out = subprocess.run(
["bash", str(HOOK)], input=json.dumps(payload),
capture_output=True, text=True, env=env,
)
assert out.returncode == 0, out.stderr
finally:
server.shutdown()
server.server_close()
return seen
return seen[0] if seen else {}
def test_hook_sends_every_definition_in_a_write(tmp_path):
@@ -1243,6 +1295,109 @@ def test_hook_sends_the_enclosing_definition_for_a_body_edit(tmp_path):
assert seen["shapes"] == ["sym:onTrash"]
@pytest.mark.asyncio
async def test_the_write_time_derive_check_names_a_family_or_a_canon_in_band():
"""#2900: the ledger's own word on the names being written — a duplicate
family with no canon, or a canon recorded elsewhere rendered at the
write even when nothing else does; keyed so the session's exclude
channel silences a family already named."""
from scribe.services import plugin_context as pc
found = [
{"symbol": "log-empty", "kind": "css", "key": "name:css:log-empty",
"family": {"group": "name:css:log-empty", "label": ".log-empty", "identical": False,
"files": ["a/TaskLogSection.vue", "a/WorkspaceTaskPanel.vue"],
"file_count": 5, "size": 6,
"consumers": {"count": 6, "paths": ["a/TaskLogSection.vue", "a/V.vue"]}}},
{"symbol": "btn-primary", "kind": "css", "key": "canon:2855",
"canon": {"snippet_id": 2855, "path": "frontend/src/assets/components.css",
"label": ".btn-primary"}},
{"symbol": "slugify", "kind": "sym", "key": "dup:483a",
"family": {"group": "dup:483a", "label": "slugify", "identical": True,
"files": ["a/x.py", "a/y.py"], "file_count": 2, "size": 3}},
{"symbol": "load", "kind": "sym", "key": "name:sym:load",
"family": {"group": "name:sym:load", "label": "load", "identical": False,
"files": ["a/X.vue", "a/Y.vue", "a/Z.vue"], "file_count": 3, "size": 4}},
]
check = AsyncMock(return_value=found)
patches = dict(
get_writepath_config=AsyncMock(return_value=_cfg()),
semantic_search_notes=AsyncMock(return_value=[]),
record_retrieval=MagicMock(), owner_names_for=AsyncMock(return_value={}),
)
with patch.multiple(pc, **patches), \
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={})), \
patch.object(pc.shape_ledger_svc, "write_time_divergence", AsyncMock(return_value=[])), \
patch.object(pc.shape_ledger_svc, "write_time_derive", check):
out = await pc.build_write_path_hint(
1, "frontend/src/components/New.vue", code=REAL_CODE, project_id=24,
stamp_shapes=[("css", "log-empty"), ("css", "btn-primary"), ("sym", "slugify"),
("sym", "load")],
exclude_derive=["name:sym:load"],
)
check.assert_awaited_once_with(24, "frontend/src/components/New.vue",
[("css", "log-empty"), ("css", "btn-primary"),
("sym", "slugify"), ("sym", "load")])
# The excluded family is gone; the other three render and are keyed.
assert [d["key"] for d in out["derive"]] == ["name:css:log-empty", "canon:2855", "dup:483a"]
assert out["derive_keys"] == ["name:css:log-empty", "canon:2855", "dup:483a"]
ctx = out["context"]
assert "Shape ledger at `frontend/src/components/New.vue`" in ctx
# A CSS family is a repeated NAME (note 2917) and its dismissal is scoped-css;
# a code dup family is an identical body and dismisses as convention-plumbing.
# A css family says what renders it (milestone 302) before the ask.
assert "`.log-empty` is a repeated name with no canon — defined in 5 other file(s): " \
"`a/TaskLogSection.vue`, `a/WorkspaceTaskPanel.vue` +3 more; used by 6 templates: " \
"`a/TaskLogSection.vue`, `a/V.vue` +4 more; derive it now" in ctx
assert "`slugify` is a duplicate family with no canon — identical body in 2 other file(s): " \
"`a/x.py`, `a/y.py`; derive it now" in ctx
assert "`.btn-primary` is canon — snippet #2855 at `frontend/src/assets/components.css`" in ctx
assert 'reason_code=\"scoped-css\")` dismisses' in ctx
assert 'reason_code=\"convention-plumbing\")` dismisses' in ctx
assert "`load`" not in ctx
# No project → no check; a failing check never sinks the hint.
check.reset_mock()
with patch.multiple(pc, **patches), \
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={})), \
patch.object(pc.shape_ledger_svc, "write_time_divergence", AsyncMock(return_value=[])), \
patch.object(pc.shape_ledger_svc, "write_time_derive", check):
out = await pc.build_write_path_hint(1, "x.py", code=REAL_CODE, stamp_shapes=[("sym", "f")])
check.assert_not_awaited()
assert out["derive"] == [] and out["derive_keys"] == []
boom = AsyncMock(side_effect=RuntimeError("ledger down"))
with patch.multiple(pc, **patches), \
patch.object(pc.snippets_svc, "list_snippets", AsyncMock(return_value=([], 0))), \
patch.object(pc.shape_ledger_svc, "recent_pulls", AsyncMock(return_value={})), \
patch.object(pc.shape_ledger_svc, "write_time_divergence", AsyncMock(return_value=[])), \
patch.object(pc.shape_ledger_svc, "write_time_derive", boom):
out = await pc.build_write_path_hint(1, "x.py", code=REAL_CODE, project_id=24,
stamp_shapes=[("sym", "f")])
assert out["context"] == "" and out["derive"] == []
def test_the_hook_keeps_a_derive_channel_and_sends_it_back(tmp_path):
"""#2900: derive keys the server returns land in the session's own
`.derive.ids` file and go back as `exclude_derive` on the next write
a family is named once per session, not at every edit."""
reply = (b'{"context":"> family","note_ids":[],"sync_note_ids":[],'
b'"derive_keys":["dup:483a","canon:2855"]}')
with http_sink(reply) as (port, seen):
env = dict(_hook_runtime_env(), SCRIBE_URL=f"http://127.0.0.1:{port}",
TMPDIR=str(tmp_path))
payload = {"session_id": "s-derive-1", "cwd": str(tmp_path), "tool_name": "Write",
"tool_input": {"file_path": str(tmp_path / "a.css"),
"content": ".log-empty {\n color: red;\n}\n"}}
for _ in range(2):
out = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload),
capture_output=True, text=True, env=env)
assert out.returncode == 0, out.stderr
assert "exclude_derive" not in seen[0]
assert seen[1]["exclude_derive"] == ["dup:483a,canon:2855"]
state = tmp_path / "scribe-priorart" / "s-derive-1.derive.ids"
assert state.read_text().split() == ["dup:483a", "canon:2855", "dup:483a", "canon:2855"]
@pytest.mark.asyncio
async def test_the_write_time_divergence_check_is_named_in_band():
"""#2793: the hook named a shape at a path whose directory a canon