Compare commits

...
61 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 2e39dca9cf fix(tasks): import minted_kind by name — a stubbed service must not stub the guard (#3129)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / integration (push) Successful in 23s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 31s
CI 4682: test_create_task_issue_sets_kind_provenance_and_systems asserted
task_kind == "issue" and got a MagicMock. That test patches the whole
notes_svc module to keep the database out, so reaching validation through
`notes_svc.minted_kind(...)` handed back a mock — the guard approved
anything and returned nothing real.

The product was wrong, not the test. minted_kind is pure validation, not a
service call, so it is imported by name. A test that stubs the service to
avoid I/O now keeps the guard intact, which is the behaviour you want from
a guard: the only way to disable it should be to say so explicitly.

That test now exercises the real validation, so it doubles as the guard
against this recurring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 15:57:06 -04:00
bvandeusenandClaude Opus 5 69d93898d9 fix(tasks): a task's kind is correctable — the Kind select stops lying (#3129)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 48s
CI & Build / Build & push image (push) Skipped
`kind` was accepted at CREATE on both doors and dropped at UPDATE on both:
update_task had no such parameter, and the REST PATCH allow-list never read
the field. So a task filed under the wrong kind could never be corrected.

The frontend made it worse by looking like it worked. TaskEditorView binds a
Kind select, marks the form dirty, and HAS ALWAYS SENT `kind` in the update
payload — the store even types it. The route ignored it, returned 200, the
view optimistically updated, the toast said "Task saved", and the old value
came back on reload. Silent success, same class as #2709.

Found by trying to re-file #3126 as a spike after deploying 0091. It could
not be done; the task had to be recreated as #3128 and the original
cancelled.

One seam, not two doors. `minted_kind()` lives in services/notes.py because
the REST route cannot import an MCP tool module and a second spelling of the
list is how the doors would come to disagree. Both create and update route
through it, so a bogus kind is now a readable error rather than a
CheckViolationError surfacing as a 500.

TaskKind joins TaskStatus and TaskPriority as a real enum, and update_note
validates task_kind exactly as it already validated those two — the field
had been reaching setattr through the hasattr guard with no validation at
all, unnoticed only because no door ever offered it.

The `-> plan` question #3129 raised is answered in code rather than left
implicit: MINTABLE_KINDS is work/issue/spike, deliberately NARROWER than the
column's CHECK. `plan` stays a valid stored value because historical
plan-tasks carry it and must stay writable; it is simply not a value any
door hands out, and the refusal names start_planning because a caller
reaching for it wants a plan. The whitelist and the policy answer different
questions and are not the same list.

Every new test reads the value BACK. One that only asserted the call
succeeded would have passed against the broken code — the route returned 200
while dropping the field, which is how this survived long enough to be found
by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 15:55:04 -04:00
bvandeusenandClaude Opus 5 15659e2c57 fix(tests): a Note's is_task cannot be set — status is what makes one (#3099)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Build & push image (push) Successful in 17s
The spike CHECK tests constructed Note(is_task=True). `is_task` is a derived
read-only property — `status is not None` — so SQLAlchemy raised
"property 'is_task' of 'Note' object has no setter" before any row reached
the database. All three failed for that, not for anything about migration
0091; the other 80 integration tests passed, including 0090's.

status="todo" is what makes a note a task. Noted inline, since the field
appears in to_dict output and reads like an ordinary column from there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 12:05:07 -04:00
bvandeusenandClaude Opus 5 88e9c0b0bd feat(tasks): task_kind gains 'spike' — the investigation, not the change (#3099, milestone 312 step 5)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Failing after 32s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Build & push image (push) Successful in 31s
A spike is a shape the other kinds cannot hold. `work` ships a change;
`issue` fixes something broken. A spike is time-boxed and its output is
KNOWLEDGE — it succeeds by producing an answer, and nothing ships at the end
of it. Filing one as `work` makes a finished investigation look like an
abandoned change, which is why the distinction earns a value rather than a
convention.

It is also the record a failed check asks for. This milestone gave rules a
verify_with; when one fails the rule is wrong, and the next move is often to
go and find out what replaced it. notes.arose_from_id already exists (0065),
so constraint -> spike provenance needed no schema at all — only a docstring
saying it is there.

Rule 36: the value and the widened CHECK land in the same migration, DROP
then ADD, exactly as 0065 did for 'issue'. The two whitelists live in one
tuple each so upgrade and downgrade cannot disagree about what the list was
on either side. The downgrade demotes existing spikes to 'work' first —
lossy, deliberately, because the alternative is a downgrade that fails on
real data, and one that says what it did beats one that cannot run.

'plan' stays whitelisted though retired: historical plan-tasks carry it, and
a row that cannot be rewritten cannot be edited, restored or migrated.

The integration test asserts both halves. A test that only proved 'spike' is
accepted would pass just as happily against a table whose CHECK had been
dropped and never re-added — which is the other way rule 36's failure
happens — so an unknown kind is asserted to still raise.

Not in scope, deliberately: any special lifecycle, time-box enforcement, or
gating relationship. It is a kind, not a workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 12:02:41 -04:00
bvandeusenandClaude Opus 5 c83bedf3be feat(rules): the check is editable, visible, and sweepable in the UI (#3098, milestone 312 step 4)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m13s
CI & Build / Build & push image (push) Successful in 37s
Rule 27 — the milestone was backend-only until this. Four surfaces:

RULE EDITOR — verify_with and expires_when under a legend that asks the
actual question ("Can this rule go stale?") and says empty is the normal
answer, because most rules are decisions and a form that implies a missing
field would get them filled in out of tidiness. When the SAVED rule carries
a check, the stamp shows with Still true / No longer true beside it. The
stamp reads the stored value, not the draft: an unsaved edit to the textarea
has not been run against anything.

SWEEP PANE — its own surface, not a filter on the rule list. That list can
only ever show one topic of one rulebook, and a rule that has gone false
belongs to no one rulebook; filtering it would under-report, which is the
failure this whole surface exists to catch. Reached from the rulebook list,
below the rulebooks, because that is where you go to look at rules.

RULE ROWS — a chip only on rules carrying a check, so its presence is the
signal. PROJECT RULES TAB — the check shows beside `why` when a rule has
one, read-only: that tab is the project's view of what binds it.

NO AGE-GRADED COLOUR anywhere, deliberately. The sweep is already ordered by
urgency, so a red/amber ramp would restate the ordering AND require an
invented "stale after N days" threshold — a magic number nobody could defend
and the first thing to go out of date. --fs-overdue is error red and reserved
for a broken promise like a missed due date; a verification age is not one,
and colouring it that way makes a rule someone just wrote look broken. Only
"never" is marked, because it is categorically different from a date rather
than a worse one — and it is marked by weight, not hue.

An empty sweep says "Nothing to check", not nothing: good news must not read
as a broken page.

Two chips (tier, then verification) turned out byte-identical, so .rule-chip
moves to rules-shared.css and snippet #2906 is updated to match rather than
left describing a file that has moved on. Its header comment counted the
panes it served; that count went stale the moment a fourth arrived, so it no
longer counts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:53:35 -04:00
bvandeusenandClaude Opus 5 9d7485df2d docs(rules): a project rule is shaped differently, not just scoped differently (milestone 312)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 28s
The three surfaces already agree on WHERE a rule goes — the using-scribe
skill's "Where a new rule goes" section and both tool docstrings frame it
as one question, who should this bind. What they did not say is that the
two homes want differently SHAPED rules, and one deferral was actively
misleading.

`create_project_rule` said `tier: "always_on" or "conditional" — see
create_rule`. That imports a bar calibrated for a different blast radius.
On a rulebook rule always_on means every session in every project, so the
test is severe: the trigger must be nameless. A project rule is already
scoped by construction, so always_on costs only that project's sessions —
and being specific, which the family test treats as the signal for
conditional, is what project rules are FOR. The instance's own data says
so: rules 78, 115 and 119 are all project rules and all always_on.

Not zero bar, a different one: conditional is right when the rule is about
one AREA of a large project, because forty always-on rules on one project
reproduces locally the preload bloat milestone 307 fixed globally.

Also:
- create_rule now says to write the general form WITHOUT hedging for
  exceptions — a project needing to narrow it writes its own and links
  with overrides/elaborates. A rulebook rule padded with "unless…" for two
  projects is two project rules that were never written. Only the project
  side mentioned that relationship; the side that benefits from it did not.
- arose_from_id: reach for it harder on a project rule, which usually comes
  from one traceable incident in the repo, where a family rule is more
  often a standing preference with no single origin.
- system_ids is worth setting on a project rule too — it is what lets a
  conditional one arrive with its area.
- when_to_apply no longer claims to "decide" the tier here, which stopped
  being true one entry down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:29:10 -04:00
bvandeusenandClaude Opus 5 410d616c22 feat(rules): the staleness sweep — which standing rules assert a fact nobody has confirmed (#3097, milestone 312 step 3)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Build & push image (push) Successful in 35s
The query the last two steps were storage for. `rules_due_for_verification`
returns every rule carrying a `verify_with`, ordered by `verified_at` ASC
NULLS FIRST, each row carrying the check IN FULL — the opposite call from
rule_brief, because the reader is about to go and run it.

NULLS FIRST is the ordering this turns on. Postgres sorts NULLs last on an
ASC ordering, which would put the rules nobody has ever confirmed BEHIND
every rule someone once looked at. Exactly backwards: a claim with no
evidence at all outranks an old one.

Rules with no check never appear, and that is the property that keeps the
list worth reading. Most rules are decisions — no truth value, nothing to go
and check. If they appeared here the sweep would be the rulebook.

`mark_rule_verified(rule_id, still_true)` closes the loop, asymmetrically:
passing writes a stamp, FAILING WRITES NOTHING. There is no "verified false"
state because a rule whose check failed is not in a special condition, it is
wrong — and recording the failure as a flag would let it sit there being
false with the sweep satisfied that someone had looked. So it stays at the
top until someone corrects or retires it, and the response says so.

An unrecognised `tier` filter raises rather than falling back. _valid_tier's
silent always_on default is right for a WRITE — a typo should leave a rule
binding — and wrong for a FILTER, where the same fallback quietly answers a
different question and returns a short list that reads as good news.

Deliberately NOT filterable by project: a project reaches rules through
project scope, subscriptions, always-on rulebooks and exclusions, and a
filter missing one of those paths would UNDER-report — the exact failure
this surface exists to prevent. Said so in the docstring rather than
shipping a half-correct filter.

Ownership-scoped like every other rule read (owned rulebook, or owned
project), in ONE statement with an OR across the XOR rather than two queries
merged in Python, so the ordering is the database's and cannot disagree with
itself. Note that rules have no sharing ACL in this schema — no rule_shares,
no rulebook_shares — so there is no wider set for access.py to consult here.

Also fixes a test title that had been lying for ten tools: "all sixteen
tools" asserted 26. The number now lives only in the assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 10:49:47 -04:00
bvandeusenandClaude Opus 5 469b43f222 test(rules): the kwargs assertion learns about clear (#3096, milestone 312 step 2)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 37s
test_update_rule_only_sends_non_default_fields pins that the MCP door
forwards only what the caller actually gave. `clear` is now always
forwarded — an empty tuple is "clear nothing", a value rather than an
absent argument — so the expected kwargs gained it. The property under
test is unchanged: everything left at its default still stays out.

Two tests added beside it while the shape is in view: naming a field for
clearing reaches the service as `clear`, and the check fields are
forwarded when given.

CI 4630 otherwise green — the integration lane ran all six of the new
real-Postgres cases (72 selected, was 66) and applied 0089 -> 0090.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:31:30 -04:00
bvandeusenandClaude Opus 5 c61925be76 feat(rules): the write path carries a rule's check, and empty finally means empty (#3096, milestone 312 step 2)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 45s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 29s
verify_with / expires_when now reach a rule through both doors and come back
on every read. The open question this step existed to settle was how to
UNSET a nullable field, and the answer is one convention per door:

- MCP: "" still means "leave unchanged" — an agent filling three fields must
  not wipe the other five — so clearing is explicit, clear_fields=["..."].
  Naming the field is the one form that cannot happen by accident.
- REST: a cleared form input arrives as "", and the service normalises "" to
  NULL for every nullable rule column, so an emptied input does what it looks
  like it does.

Two idioms, one outcome, and the normalisation is what makes the step-3 sweep
correct: `verify_with IS NOT NULL` would otherwise be true for every rule ever
touched through the UI, and the sweep would list the whole rulebook and mean
nothing. to_dict renders "" and NULL identically, so this is only visible
against a real column — hence the integration module rather than a mock.

Editing verify_with drops verified_at. A stamp certifies A CHECK, not a rule;
reword the check and the old stamp vouches for something that no longer
exists. Safe direction, same asymmetry as _valid_tier: a rule wrongly listed
as due costs one look, a rule wrongly vouched for costs the thing the sweep
exists to catch. Editing anything else leaves the stamp alone, or a rulebook
tidy-up would reset every constraint and the ordering would carry nothing.

Reads: rule_brief attaches `last_verified` ONLY to a rule that carries a
check — its presence is the signal, and it says both "this asserts a fact
that can go false" and "here is how long ago anyone confirmed it". "never"
rather than null, per #2483. The check text itself stays in get_rule; a
listing needs to know which rules can rot, not how to test them. Search hits
carry the full trio, since a hit is exactly the moment someone is about to
act on a rule.

Also folds in the #3078 finding, which had been sitting as a note: create_rule
now teaches that when_to_apply is the retrieval surface and must carry the
SYMPTOM — the words you would type while stuck — not just the situation.

fake_rule gains the three fields as None for the reason the helper already
documents one line up: unnamed, verify_with is a truthy MagicMock and every
stand-in rule would claim a check it does not have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 09:28:03 -04:00
bvandeusenandClaude Opus 5 e08e999406 feat(rules): a rule can carry its own check — verify_with, expires_when, verified_at (#3095, milestone 312 step 1)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / integration (push) Successful in 28s
CI & Build / Build & push image (push) Successful in 25s
A rulebook holds two kinds of row in one table. A NORM is a decision: no
truth value, changes only when its author changes it, and they know they
did. A CONSTRAINT asserts a fact about someone else's software, and goes
false with nobody present. Milestone 307's audit found nine stale sites;
every one was a constraint, and not one norm had rotted.

Three nullable columns so a rule can say how to check itself. expires_when
is a STATE, not a date — constraints expire when the ground moves, not on a
schedule. verified_at NULL means never checked and sorts FIRST in the sweep
to come: unexamined outranks examined-long-ago. Most rules set none of the
three; a null verify_with is the marker for "this is a decision, there is
nothing to go and check," and it only reads that way while it stays honest.

Nothing is backfilled and nothing is indexed. A migration cannot invent a
check any more than 0088 could invent a trigger, and the sweep reads a whole
rulebook — hundreds of rows, on operator demand, never on a request path.

Also, in the backup service the fields had to pass through:

- Restore now remaps arose_from_id through note_id_map. It has been exported
  since 0088 and silently dropped on the way back in ever since, so every
  restore lost every rule's provenance link.
- _dt_or_none, because _dt substitutes now() for an absent value. That is
  right for created_at/updated_at and wrong here: a rule nobody ever checked
  would restore looking freshly checked and fall to the bottom of the sweep
  it should top.

Column additions do not move BACKUP_VERSION; only new sections do, as when
0088 added when_to_apply/tier/arose_from_id to the same helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 07:44:13 -04:00
bvandeusenandClaude Opus 5 02c1e37620 feat(rules): the write path can notice a standing rule it was never given (#3031, milestone 307 step 5, hook arm)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 24s
A conditional rule is not resident, so a session can be about to violate one it
was never handed. This arm notices: when what is being written resembles a
rule's trigger, the hint names it and says to read it before deciding it does
not apply.

A SUGGESTION, and the plan was wrong about why it could be more. It claimed the
hook "already resolves a path to an area" — it does not, and nothing in Scribe
maps a path to a System or a canonical area (build_write_path_hint resolves
paths against snippet LOCATIONS, a different index; the learned-alias idea
belongs to another project). Correction logged on the task. Rather than invent
path→area inference to make a stale claim true, the arm does what D7 already
decided and what this surface already IS: tags bind at enter_project, meaning
suggests here. The header of the hook says NEVER BLOCKS; dressing a hint up as
binding would have been the actual mistake.

CONDITIONAL RULES ONLY. An always-on rule is already in the session, so
re-offering it is noise — and noise on a hint that fires on every write is how
a hint gets ignored.

Telemetry goes to retrieval_logs, NOT note_usage_events, and that is a
correctness call rather than a preference: note_usage ids are REMAPPED on a
backup restore, so a rule id written there would come back attached to whatever
note took that number — silently corrupting the evidence the next true-up is
supposed to read. retrieval_logs is never restored and `source` already
separates surfaces. record_retrieval's `results` type widened to match what it
actually needs (an `.id`), instead of passing a Rule to something annotated Note.

The rule dedup gets its OWN state file and query parameter, like the three
channels before it — #2708's lesson was that one shared channel lets a hint of
one class silence a different class that had never been shown. Plugin version
bumped: a hook change clients cannot see did not ship (#1040).

The stub is autouse in conftest rather than added to forty-odd call sites: the
arm loads an embedding model, and every existing test that stubs the NOTES
search would otherwise pull a real model in through the one arm it had no way
to know about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 16:35:36 -04:00
bvandeusenandClaude Opus 5 4585cda3ff fix(rules): import or_ in rulebooks — the tier gate used it unimported (#3031)
CI & Build / Python lint (push) Successful in 7s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m9s
CI & Build / Build & push image (push) Successful in 24s
NameError at runtime: the tier/area clause and co_surfaced_partners both build
an or_(), which rulebooks.py never imported. py_compile passes on this (it is
a name error, not a syntax error) and so did the whole unit suite.

Where it surfaced is the useful part. The unit tests mock the session, so the
project-areas query returns empty, `reachable` stays None, and the or_ branch
is never taken — they exercised the path that avoids the bug. Only the
integration lane, with a real project and real rows, went down the branch that
needed the name. Six integration tests caught it, including the inception one
that merely calls get_applicable_rules in passing.

A reminder about which lane proves what: mocking the thing that selects the
branch means the branch is untested by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 15:13:40 -04:00
bvandeusenandClaude Opus 5 cd9aa87aa4 feat(rules): tier 1 preloads, tier 2 arrives by area — and a split rule can no longer be read half-way (#3031, milestone 307 step 5)
CI & Build / Python lint (push) Failing after 6s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Failing after 26s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Skipped
The payoff step: a rule stops having to be resident to be honoured.

- list_always_on_rules returns the ALWAYS-ON tier only. It is the session-start
  call, made before any project is in scope, so there is no area vocabulary to
  match a conditional rule against yet.
- get_applicable_rules carries a conditional rule when the project works in an
  area the rule is tagged to — resolved through systems.canonical_id, so the
  project's own NAME for the area is irrelevant, which is the entire reason the
  catalog exists. The gate is applied IN SQL, so `limit` counts rules that will
  actually surface rather than rules about to be dropped.
- Bindingness is a deterministic TAG match, never a similarity score (D7). The
  vector channel stays a suggestion, in search.

co_surfaced_partners is the fix that rule 144 never had. It was split off rule
46 and folded back the same day because "either rule could surface without the
other and miss exposing a project to what the entire shape is intended to be" —
correct, and merging was the only remedy available. Now a partner ARRIVES with
its other half even when nothing else selected it, tagged `via: co_surfaces` so
the payload says why. Two limits, both deliberate: only rules the caller owns,
because an edge is not a back door into someone else's rulebook; and a
project's suppressions are passed as exclusions, because an explicit mute is a
decision and an edge does not outrank it.

COMPATIBILITY, asserted first in the integration test rather than reasoned
about: a rule with no tier, no areas and no edges binds exactly as it did
before any of this existed. `tier` defaults to always_on, so an install
upgrades and every rule it already had keeps arriving. Getting that backwards
would silently stop enforcing rules people rely on, which is worse than any
amount of payload bloat.

Four unit tests were coupled to the ORDER of a mocked session's execute()
calls, so a new query broke them. Rather than pad the sequence and deepen that
coupling, the three post-query lookups are stubbed by name — they have their
own coverage, and the real wiring is proven against Postgres.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 15:10:41 -04:00
bvandeusenandClaude Opus 5 6ada97bb0b fix(rules): resolve the Rule forward ref for ruff and readers (#3030)
CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 33s
CI & Build / TypeScript typecheck (push) Successful in 44s
CI & Build / Python tests (push) Successful in 1m11s
CI & Build / Build & push image (push) Successful in 23s
F821: the quoted `"Rule"` in semantic_search_rules' return annotation
evaluates fine at runtime — a string inside a subscript is a value, not a name
lookup — but it points at nothing a reader or a type checker can follow, which
is what ruff is objecting to and it is right to.

A TYPE_CHECKING import resolves it at zero runtime cost, while the real import
stays inside the function so this module still does not pull in the rulebook
models. Placed after the import block, where isort wants the guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 15:03:40 -04:00
bvandeusenandClaude Opus 5 95a37318fc feat(rules): rules become findable by meaning (#3030, milestone 307 step 4)
CI & Build / Python lint (push) Failing after 9s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 44s
CI & Build / integration (push) Successful in 45s
CI & Build / Python tests (push) Successful in 1m26s
CI & Build / Build & push image (push) Skipped
Rules were the only major record type with no vector, so `search` could never
return one and a rule could arrive only by being preloaded. That single fact is
what made every rule compete for one always-on budget.

THE DECISION THE TASK ASKED FOR, made explicitly: a sibling rule_embeddings
table, not a polymorphic embedding row. The ROW could have been generalised;
the SEARCH could not. semantic_search_notes is Note-specific scoping end to end
— the visibility clause, the supersession penalty, note_type/task_kind/system
filters — and a rule shares none of it, scoping instead by rulebook ownership
or project. Generalising the row while still needing two searches is the worst
of both: a key with referential integrity to neither table, on the path every
session start runs, to share four columns. What is genuinely common is
BEHAVIOUR — get_embedding, chunk_document, embedding_text, CHUNKER_VERSION —
and those are reused as-is. Sharing them is the DRY win; sharing the table
would have been the DRY costume.

The document shape is measured, not chosen (note 2485). That pass found the
snippet was the only discriminative record in the corpus — a 0.153
top-to-second gap against 0.010-0.023 — and that the cause was its SHAPE:
purpose stated twice in a short single-topic document. rule_document
reproduces it: the trigger in the title AND as the body's first line.

And it excludes `why`, which matters more than any of it. `why` is dated
incident narrative — rule 46's runs to 4,300 characters — and long multi-topic
prose is exactly what made sixteen dev-logs mutually indistinguishable. Adding
it would not give the vector more to work with; it would give every rule the
SAME thing to work with. rule_document takes no `why` parameter at all, so a
well-meaning caller cannot pass one.

A rule with no trigger degrades to title + statement — findable, less sharp.
That is an argument for backfilling triggers (step 6), not for padding the
document with whatever text is nearby.

search(content_type="rule") returns the rule WITH its why and how_to_apply:
they are its operational half, the session payload never carries them, and a
caller who went looking should not have to re-fetch. Writes re-index
fire-and-forget like notes; startup backfills in its own try block so neither
backfill can skip the other. rule_embeddings is derived, so it joins
note_embeddings in the backup's explicitly-NOT-included list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 15:00:49 -04:00
bvandeusenandClaude Opus 5 682bea5257 fix(rules): the third row literal — fetchRules builds a list row too (#3029)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 20s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Successful in 1m3s
CI & Build / Build & push image (push) Successful in 40s
vue-tsc caught what I missed: there were THREE places hand-building a rule
list row, not two. fetchRules mapped full rules down to the same four fields
in a spot far from the other two, so consolidating the pair I could see left
this one behind — which is precisely how the server side ended up with three
divergent trim dicts in the first place.

All three now go through toHeader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:26:20 -04:00
bvandeusenandClaude Opus 5 8b60d552d2 feat(rules): the rule editor asks when it applies, and the list shows its age (#3029, milestone 307 step 3, UI)
CI & Build / Python lint (push) Successful in 6s
CI & Build / integration (push) Successful in 38s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Failing after 27s
CI & Build / Python tests (push) Successful in 1m27s
CI & Build / Build & push image (push) Skipped
Rule 27 — the schema and both doors shipped with no human surface, so step 3
was not shippable until this.

RuleEditorSlideOver gains the trigger, the tier, the areas, and a read-only
view of the rule's edges. The tier is a radio pair carrying the test itself
rather than a bare toggle: can you name the trigger WITHOUT naming a system,
an artifact type or a moment? If the honest answer is "whenever you are
working", it is always on. It also says why conditional is not a demotion —
it costs nothing when irrelevant, which is what lets a rule be as long as it
needs to be. The relations block states the rule the whole milestone turns on:
rules that FAIL TOGETHER are linked, never merged.

RuleListPane shows the trigger and the LAST-CHANGED DATE on every row, and
marks conditional only — always_on is the default and badging every row would
say nothing. The date is the cheap triage the FabledCurator case wanted: a
rule whose age predates the capability it duplicates is visible at a glance
instead of needing a get_rule to find out.

ProjectRulesTab's inline create form gains the same two fields, because a
project rule bloats exactly the way a family one does — FabledCurator has 23
of them.

Two type fixes the new shapes forced, both worth keeping:
- toHeader() in the store: a list row is the server's rule_brief, so patching
  a list locally has to mirror every field it carries or the two disagree.
  There were two hand-built four-field literals doing that job.
- ApplicableRules.rules / .project_rules are now described AS RuleHeader
  rather than as two more hand-written shapes — the same builder produces
  them, so the same type should describe them.

groupByRulebookAndTopic skips a null-topic rule rather than widening
TopicGroup to accept one: a rule carries topic_id XOR project_id, so a null
topic in that list means something is wrong upstream, and a widened type would
hide it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:23:40 -04:00
bvandeusenandClaude Opus 5 ffb7a0fe38 feat(rules): both doors carry the trigger, the tier, the areas and the edges (#3029, milestone 307 step 3, surfaces)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 24s
MCP and REST both gain when_to_apply / tier / system_ids / arose_from_id on
create and update, plus relate_rules / unrelate_rules for the typed edges, and
get_rule now returns a rule's areas and relations alongside it.

rule_detail() is a SERVICE function, not one per door. It started as a copy in
each — identical, and the prior-art hook flagged it immediately, which is the
same lesson rules_payload (#2858) already recorded: a second copy drifts. Both
doors call the one seam, so create, update and get cannot disagree about what a
rule looks like coming back.

The authoring guidance lands in create_rule's docstring rather than in a rule,
per rule 119 as the operator described it: this is behaviour every instance
should inherit, not one operator's preference. It states the test —

  ONE RULE = ONE THING YOU COULD VIOLATE. Rules that FAIL TOGETHER get linked
  with relate_rules(kind="co_surfaces"), never merged into one row.

— and names why merging loses: a merged rule cannot be cited, surfaced or
suppressed a clause at a time, and it grows without limit because adding to it
is always cheaper than adding a rule. create_project_rule says the same about
"overrides", which is what FabledCurator's 85/86 should have been instead of
near-copies that drift from their parent.

The tier arg carries the test itself: can you name the trigger WITHOUT naming a
system, an artifact type or a moment? If the honest answer is "whenever you are
working", it is always_on.

Tests: the applicable-rules cases fabricated raw tuples matching the old column
lists, so they move to the entity shape via fake_rule; new cases pin rule_brief
(a DATE not a stamp, the depth left to get_rule, no null keys) and that an
unknown tier falls back to BINDING. fake_rule gains when_to_apply / tier /
arose_from_id for the note-2109 reason the helper exists: unnamed, they would
be truthy MagicMocks. The tool tests stub the new rule_detail seam — they are
about argument forwarding and have no database.

The module header's "Sixteen tools" had been wrong for two milestones; the
registration count test is what actually catches that, so the header now says
so instead of carrying a number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:17:41 -04:00
bvandeusenandClaude Opus 5 6ddb8bf859 feat(rules): a rule can say when it applies, which area it is about, and what it belongs with (#3029, milestone 307 step 3, schema)
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 33s
CI & Build / Python tests (push) Failing after 47s
CI & Build / Build & push image (push) Skipped
A rule could not state its trigger, its area, or its siblings, so all three
were being written as prose instead: a System's charter restating rule text,
a `why` naming the note that caused it, and two halves of one shape merged
into a single row because either could surface without the other.

Migration 0088 adds the four fields those workarounds stood in for:

- `when_to_apply` — the trigger. Nullable in the DB and required at the
  service layer: existing rules have none and a migration cannot invent one.
- `tier` — always_on | conditional, defaulting to always_on. This migration
  therefore changes NOTHING about which rules bind; an install upgrades and
  every rule keeps arriving exactly as before. Getting that backwards is the
  one failure this milestone exists to prevent, so _valid_tier falls back to
  always_on rather than silently un-binding a rule with a typo'd tier.
- `arose_from_id` — the record that caused the rule, the edge notes and tasks
  already have. SET NULL: trashing the source does not repeal the rule.
- `rule_systems` / `rule_relations` — the canon tag and the typed edges
  (co_surfaces / overrides / elaborates), each earned from a workaround its
  absence forced.

rule_brief() replaces the THREE hand-written trim dicts that had already
diverged — two carried topic_id, one didn't, and none carried the timestamps
the model has held all along. That omission is why a rule written before the
capability it duplicates was indistinguishable at read time from one still
doing work. It now carries updated_at as a DATE: the question is "how old is
this", and a full stamp across the always-on set is ~2k characters for
precision nobody reads. The two callers select the ENTITY rather than a column
list, so rule_brief stays the single place deciding what a surfaced rule says.

Backup: both new tables carried, area tags by canonical SLUG (ids are
per-install). The rule-relation restore runs after ALL rules exist and after
the catalog, because an edge names two rules and a tag names a global row —
sections renumbered so the file reads in dependency order. A pre-0088 payload
restores with tier=always_on, i.e. binding exactly as when it was taken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 14:11:32 -04:00
bvandeusenandClaude Opus 5 67874268bb test(systems): move the name-gate cases to the service the gate moved into (#3028)
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 22s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 35s
Two tests further down test_mcp_tool_systems.py still drove the gate through
the tool — `svc.list_systems` stubbed, the tool doing the normalising — so the
new `await systems_svc.assess_system_name(...)` hit an unstubbed MagicMock.

Stubbing them at the tool would have kept testing the wrong layer. The
normalisation cases belong with the logic, so they move to
tests/test_services_systems.py as real coverage of assess_system_name: case and
whitespace folding, exact-beats-overlap (and that an exact hit short-circuits
the lesser lookup), no invented match, fail-open on both arms, and silence for a
nameless system.

What stays the tool's job — rendering a duplicate, applying an exact area,
offering an overlap — is already covered at the top of that file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:02:09 -04:00
bvandeusenandClaude Opus 5 c58529718b feat(systems): the catalog reaches the moment a name is minted, and gets a face (#3028, milestone 307 step 2)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 48s
CI & Build / integration (push) Successful in 38s
CI & Build / Python tests (push) Failing after 57s
CI & Build / Build & push image (push) Skipped
Step 1 found the reason the standard names never held, and it is sharper than
"prose doesn't fire": the list WAS real and it WAS seeded — but only on the
inception path, for a project with zero Systems. Ad-hoc create_system never
consulted it, which is how Forge minted "CI and Release" and Portal minted
"CI & release" after the constant already existed. This wires the vocabulary to
the moment that mints a name.

- services/systems.assess_system_name: the local duplicate gate AND the
  catalog lookup, in ONE service function both doors call. The gate lived only
  in the MCP tool, which is exactly how the web UI shipped without a check the
  agent surface enforced (#2482). REST now answers 409 with the System that
  already covers the area.
- An `exact` catalog hit is APPLIED (mechanical — the names differ only in
  spelling). An `overlap` is only OFFERED, on both doors: applying a judgment
  call silently is how a cross-project rule surfaces in the wrong project.
- canonical_systems.best_overlap is the ONE scorer behind the create-time
  offer and the review sweep, so the two surfaces can never name different
  areas for one System. It also takes the catalog the caller already holds,
  so the review is not an N+1.

UI (folded in from step 1 — rule 27, that step shipped with no human surface):
- SystemsSection: a Shared area picker on create and edit, the area on each
  card, and a collapsed review of proposals that appears only when there is
  something to decide. `exact` and `overlap` never share a style — one is
  mechanical, the other is the reviewer's judgment, and presenting them alike
  is how a wrong mapping gets waved through.
- Settings → Admin → Areas: the catalog itself, showing each entry's slug,
  because the slug is what decides whether two names are the same area and a
  rename moves it.
- A picker rather than a live matcher: reproducing the slug rule in TypeScript
  would give this feature two matchers to keep in step — the exact drift the
  catalog exists to end. The server stays authoritative.

tests/helpers.fake_system gains canonical_id=None: an unnamed attribute is an
auto-MagicMock and therefore truthy, which is the trap that helper exists for
(note 2109) and a nullable FK walks straight into it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:58:30 -04:00
bvandeusenandClaude Opus 5 879ef3053e fix(systems): the bootstrap ask reads the catalog, so its test must supply one (#3027)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m20s
CI & Build / Build & push image (push) Successful in 38s
CI caught the seam the promotion opened: the standard names now come from an
async catalog read, and the unit test patches systems_svc wholesale — so the
read raised, the fail-open swallowed it, and the ask shipped without the names
it is supposed to carry.

Stub standard_systems in that test, and assert the wiring rather than the
vocabulary: THAT the seeded set is these eight is migration 0087's business and
belongs in the inception integration test, against a real database.

Adds the case the promotion actually created — an unreachable or empty catalog
must still produce the ask. The names are an aid to the question, not the
question; degrading to a weaker nudge is fine, going silent is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:34:29 -04:00
bvandeusenandClaude Opus 5 a97547fbc6 feat(systems): the area vocabulary becomes a global table so a rule can point at one (#3027, milestone 307 step 1)
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 30s
CI & Build / Python tests (push) Failing after 56s
CI & Build / Build & push image (push) Skipped
The eight standard area names already existed — as STANDARD_SYSTEMS, a tuple in
services/systems.py that milestone 297 seeds at inception. A constant cannot be
a foreign key, so nothing outside a project could reference an area: systems.
project_id is NOT NULL, and a rule that spans projects would have to chain
itself to one project's row. And because the list only ever applied on the
inception-seed path, three spellings of one area reached this instance anyway
(CI & runners / CI and Release / CI & release).

- canonical_systems: global, no user_id — a shared project inherits the
  vocabulary instead of re-earning it. Migration 0087 seeds the same eight.
- systems.canonical_id: nullable, SET NULL. Association only — no System is
  renamed and record_systems is untouched, so no record's tags move.
- canonical_slug folds &/and, case and punctuation, so spelling variants map
  mechanically and a real difference ("CI & runners") becomes a proposal a
  human confirms. propose_mappings reports; set_system_canonical is the only
  writer.
- seed_standard_systems now reads the catalog and maps as it mints, so a
  project born standard never needs a reconciliation pass.
- Catalog writes are admin-only; reads are open — a global list anyone can
  extend stops being shared.
- backup: carried by SLUG, not id (ids are per-install). Restore reuses the
  target's own rows and only creates entries an admin added on the source; an
  unknown slug restores unmapped rather than failing.

Rule 22: STANDARD_SYSTEMS is removed, not deprecated. Rule 115: nothing seeded
names an app, repo or house convention. Design in note 3026.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 12:31:18 -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
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
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
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
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
128 changed files with 10501 additions and 3338 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")
+109
View File
@@ -0,0 +1,109 @@
"""canonical_systems — the global area vocabulary, promoted from a constant
to a table (milestone 307 step 1, decision note 3026)
Revision ID: 0087
Revises: 0086
Create Date: 2026-08-26
The eight standard area names already existed as `STANDARD_SYSTEMS`, a tuple in
services/systems.py that milestone 297 seeds into a project at inception. A
constant cannot be referenced: a rule that applies across projects has nothing
to point at, because `systems.project_id` is NOT NULL and a family rule cannot
be chained to one project's row. This makes the vocabulary a table so it can be
a foreign key, and adds the nullable `systems.canonical_id` that maps a
project's local System onto it.
Deliberately no `user_id`: the catalog is GLOBAL so a shared project inherits
the vocabulary rather than re-earning it. `record_systems` is untouched — it
joins note_id/system_id and never sees this table, so no association data
moves, and no System's own `name` is rewritten.
The seed rows are written here verbatim rather than imported from the service:
a migration is a historical record and must keep running unchanged after the
service's list moves on.
"""
import sqlalchemy as sa
from alembic import op
revision = "0087"
down_revision = "0086"
branch_labels = None
depends_on = None
# (name, slug, description) — the milestone-297 vocabulary, with the slug the
# service computes (canonical_slug: lowercase, "&" -> "and", non-alphanumerics
# collapsed to "-"). Charters stay generic on purpose: a project refines its
# own System's description, never this one. Nothing here names an app, a repo,
# a vendor or a house convention — the catalog ships to every install (rule 115).
_SEED = (
("CI & Release", "ci-and-release",
"How the project is verified and shipped: pipelines, runners, image/artifact builds, release tagging and rollback."),
("Auth & Access", "auth-and-access",
"Who may do what: identity, sessions/tokens, permissions and the scoping of every read and write to the right users."),
("Data Model & Storage", "data-model-and-storage",
"What is stored and how it is shaped: the schema, migrations, serialisation and the services that own a table's lifecycle."),
("API Surface", "api-surface",
"The doors into the capability: HTTP routes, tool/RPC surfaces, request parsing, error envelopes and their contracts."),
("UI & Design", "ui-and-design",
"What people see and touch: views, components, client state, and the design tokens/recipes they are built from."),
("Import & Export", "import-and-export",
"Data crossing the boundary: backups, exports, imports, sync with other systems, file formats."),
("Background Jobs", "background-jobs",
"Work that runs without a request: schedulers, queues, periodic ticks, retention and maintenance."),
("Observability", "observability",
"How the system reports on itself: logging, metrics, audit trails, health and diagnostics."),
)
def upgrade() -> None:
canonical_systems = op.create_table(
"canonical_systems",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("slug", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
)
# Unique among LIVE rows only, so a soft-deleted entry doesn't block
# recreating or restoring the same area (the rules/topics convention).
op.create_index(
"uq_canonical_systems_slug", "canonical_systems", ["slug"],
unique=True, postgresql_where=sa.text("deleted_at IS NULL"),
)
op.bulk_insert(
canonical_systems,
[
{"name": name, "slug": slug, "description": description, "order_index": index}
for index, (name, slug, description) in enumerate(_SEED)
],
)
op.add_column(
"systems",
sa.Column("canonical_id", sa.Integer(), nullable=True),
)
# SET NULL, not CASCADE: retiring a catalog entry must never delete a
# project's System along with it.
op.create_foreign_key(
"fk_systems_canonical_id", "systems", "canonical_systems",
["canonical_id"], ["id"], ondelete="SET NULL",
)
op.create_index("ix_systems_canonical_id", "systems", ["canonical_id"])
# Existing Systems are left UNMAPPED on purpose. An exact-slug match would
# be safe, but a near miss ("CI & runners" vs "CI & Release") is a judgment
# call — those go through the propose/confirm path so a human approves each
# one, rather than being decided by a migration nobody reviews.
def downgrade() -> None:
op.drop_index("ix_systems_canonical_id", table_name="systems")
op.drop_constraint("fk_systems_canonical_id", "systems", type_="foreignkey")
op.drop_column("systems", "canonical_id")
op.drop_index("uq_canonical_systems_slug", table_name="canonical_systems")
op.drop_table("canonical_systems")
@@ -0,0 +1,105 @@
"""rules gain a trigger, a tier, canon tags and typed edges (milestone 307
step 3, decision note 3026)
Revision ID: 0088
Revises: 0087
Create Date: 2026-08-26
A rule could not say WHEN it applies, WHICH area it is about, or WHAT other
rule it belongs with. All three were being written as prose instead — a
project's System description restating rule text, a rule's `why` naming the
note that caused it, and two halves of one shape merged into a single row
because either could surface without the other.
Four additions, each replacing something that was already being said in words:
- `when_to_apply` — the trigger. Nullable HERE and required at the service
layer, because existing rules have none and a migration cannot invent one.
- `tier` — `always_on` (preloaded, as everything is today) or `conditional`
(reachable, surfaced when its trigger fires). Defaults to `always_on`, so
this migration changes NOTHING about which rules bind: an install upgrades
and every rule keeps arriving exactly as it did.
- `arose_from_id` — the record that caused the rule, the edge notes and tasks
already have.
- `rule_systems` / `rule_relations` — the canon tag and the typed edges.
"""
import sqlalchemy as sa
from alembic import op
revision = "0088"
down_revision = "0087"
branch_labels = None
depends_on = None
# Kept in one place so upgrade and the CHECK agree by construction (rule 36:
# a whitelisted value means DROP + ADD CONSTRAINT in the same migration —
# there is no prior constraint here, so the pair is created together).
_TIERS = ("always_on", "conditional")
_RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
def _in_list(column: str, values: tuple[str, ...]) -> str:
return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")"
def upgrade() -> None:
op.add_column("rules", sa.Column("when_to_apply", sa.Text(), nullable=True))
op.add_column(
"rules",
sa.Column("tier", sa.Text(), nullable=False, server_default="always_on"),
)
op.create_check_constraint("ck_rules_tier", "rules", _in_list("tier", _TIERS))
# SET NULL, not CASCADE: the record that prompted a rule can be trashed
# without taking the rule with it — provenance is a claim about history,
# and losing the source does not repeal the rule.
op.add_column("rules", sa.Column("arose_from_id", sa.BigInteger(), nullable=True))
op.create_foreign_key(
"fk_rules_arose_from_id", "rules", "notes",
["arose_from_id"], ["id"], ondelete="SET NULL",
)
# Which global AREA a rule is about. Points at the canonical catalog, never
# at a project's `systems` row — a rule that spans projects cannot be
# chained to one project's vocabulary (0087).
op.create_table(
"rule_systems",
sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
sa.Column("canonical_id", sa.Integer(), sa.ForeignKey("canonical_systems.id", ondelete="CASCADE"), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
op.create_index("ix_rule_systems_canonical_id", "rule_systems", ["canonical_id"])
# Typed edges between rules. Each kind exists because its absence forced a
# workaround: co_surfaces (merging two rules into one row), overrides (a
# stricter project rule written as a duplicate), elaborates (a local
# addendum sitting beside its parent with nothing to say it is one).
op.create_table(
"rule_relations",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column("from_rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False),
sa.Column("to_rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.CheckConstraint(_in_list("kind", _RELATION_KINDS), name="ck_rule_relations_kind"),
# A rule cannot relate to itself, and one pair carries a given kind
# once — a second row would surface the same rule twice.
sa.CheckConstraint("from_rule_id <> to_rule_id", name="ck_rule_relations_not_self"),
sa.UniqueConstraint("from_rule_id", "to_rule_id", "kind", name="uq_rule_relations_edge"),
)
op.create_index("ix_rule_relations_from", "rule_relations", ["from_rule_id"])
op.create_index("ix_rule_relations_to", "rule_relations", ["to_rule_id"])
def downgrade() -> None:
op.drop_index("ix_rule_relations_to", table_name="rule_relations")
op.drop_index("ix_rule_relations_from", table_name="rule_relations")
op.drop_table("rule_relations")
op.drop_index("ix_rule_systems_canonical_id", table_name="rule_systems")
op.drop_table("rule_systems")
op.drop_constraint("fk_rules_arose_from_id", "rules", type_="foreignkey")
op.drop_column("rules", "arose_from_id")
op.drop_constraint("ck_rules_tier", "rules", type_="check")
op.drop_column("rules", "tier")
op.drop_column("rules", "when_to_apply")
+58
View File
@@ -0,0 +1,58 @@
"""rule_embeddings — rules become findable by meaning (milestone 307 step 4,
decision note 3026)
Revision ID: 0089
Revises: 0088
Create Date: 2026-08-26
Rules were the only major record type with no vector, so `search` could never
return one and a rule could only ever arrive by being preloaded. That single
fact is what made every rule compete for the same always-on budget.
A sibling table rather than a generalisation of note_embeddings: the row could
have been made polymorphic, but the SEARCH could not — semantic_search_notes is
Note-specific scoping end to end, and a rule shares none of it. See the model
docstring for the full reasoning.
The vectors are DERIVED data. Nothing is backfilled here: the startup backfill
regenerates them, which is also how a chunker-version bump is handled.
"""
import sqlalchemy as sa
from alembic import op
revision = "0089"
down_revision = "0088"
branch_labels = None
depends_on = None
# Matches note_embeddings — bge-small-en-v1.5, 384-dim unit-normalized.
_EMBEDDING_DIM = 384
def upgrade() -> None:
op.create_table(
"rule_embeddings",
sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
sa.Column("chunk_index", sa.Integer(), primary_key=True),
sa.Column("chunk_text", sa.Text(), nullable=False),
sa.Column("chunker_version", sa.Integer(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
# The vector column is added by raw DDL for the same reason 0067 did it:
# the type comes from the pgvector extension, not from SQLAlchemy's
# type system.
op.execute(f"ALTER TABLE rule_embeddings ADD COLUMN embedding vector({_EMBEDDING_DIM}) NOT NULL")
# HNSW for cosine distance — matches Vector.cosine_distance (`<=>`), so the
# search is an indexed ORDER BY ... LIMIT k rather than a full scan.
op.execute(
"""
CREATE INDEX ix_rule_embeddings_embedding_hnsw
ON rule_embeddings
USING hnsw (embedding vector_cosine_ops)
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_rule_embeddings_embedding_hnsw")
op.drop_table("rule_embeddings")
@@ -0,0 +1,64 @@
"""a rule can carry its own check — verify_with, expires_when, verified_at
(milestone 312 step 1)
Revision ID: 0090
Revises: 0089
Create Date: 2026-08-27
A rulebook holds two kinds of row in one table. A NORM is a decision: it has
no truth value, and it changes only when its author changes it — which they
know they did. A CONSTRAINT is a fact about someone else's software: a
runner's shell, a bot's config, a tool that exists. Nobody is present when
that goes false.
Milestone 307's rulebook audit found nine stale sites. Every one was a
constraint; not one norm had rotted. One of them had been telling every
session to skip database-backed tests for weeks while the integration lane
sat green in the workflow.
Three nullable columns, so a rule can say how to check itself:
- `verify_with` — how to tell whether this is still true. A command, a path,
a URL, a query. Prose is allowed; something runnable is better.
- `expires_when` — the STATE under which it stops being true. Deliberately
not a date: constraints do not expire on a schedule, they expire when the
world underneath them moves.
- `verified_at` — when the check last passed. NULL means never checked, and
sorts FIRST in the sweep: unexamined outranks examined-long-ago.
All three nullable and all three optional, because most rules should set
none of them. A null `verify_with` is not an omission — it is the honest
marker of "this one is a decision, and there is nothing to go and check."
That signal only works if the field stays empty wherever it belongs empty.
No CHECK constraint is involved, so rule 36 does not apply here. Nothing is
backfilled: a migration cannot invent a check any more than 0088 could
invent a trigger.
"""
import sqlalchemy as sa
from alembic import op
revision = "0090"
down_revision = "0089"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("rules", sa.Column("verify_with", sa.Text(), nullable=True))
op.add_column("rules", sa.Column("expires_when", sa.Text(), nullable=True))
op.add_column(
"rules",
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
)
# No index on (verify_with, verified_at). The sweep this exists for reads
# an operator's whole rulebook — hundreds of rows, not millions — and runs
# when a human asks for it, never on a request path. An index here would
# be maintained on every rule write to serve a query that a sequential
# scan answers instantly.
def downgrade() -> None:
op.drop_column("rules", "verified_at")
op.drop_column("rules", "expires_when")
op.drop_column("rules", "verify_with")
+66
View File
@@ -0,0 +1,66 @@
"""task_kind gains 'spike' — the investigation, not the change
(milestone 312 step 5)
Revision ID: 0091
Revises: 0090
Create Date: 2026-08-27
A spike is a task shape the others cannot hold. `work` ships a change;
`issue` fixes something broken. A spike is time-boxed and its output is
KNOWLEDGE — it succeeds by producing an answer, and nothing ships at the
end of it. "Find out whether the runner can be given a bash shell" is not
work, and filing it as work makes a finished investigation look like an
abandoned change.
It is the record a failed check asks for. Milestone 312 gave rules a
`verify_with`; when one of those fails, the rule is wrong and the next move
is often to go and find out what replaced it. `notes.arose_from_id` already
exists (0065), so that constraint -> spike link needs no further schema.
Rule 36: `task_kind` is gated by a CHECK whitelist, so the value and the
widened constraint land in the SAME migration — DROP then ADD, exactly as
0065 did when it introduced 'issue'. Adding the value and constraining it
later leaves a window where the database accepts anything.
'plan' stays in the list though it is retired (plans are milestones since
0066): historical plan-tasks still carry it, and dropping it from the
whitelist would make old rows unwritable.
"""
from alembic import op
revision = "0091"
down_revision = "0090"
branch_labels = None
depends_on = None
# One tuple so the upgrade and the downgrade cannot disagree about what the
# list was on either side of this migration.
_KINDS_AFTER = ("work", "plan", "issue", "spike")
_KINDS_BEFORE = ("work", "plan", "issue")
# Restated rather than imported from 0088, which has the same helper. A
# migration is a snapshot: it must keep working when the code around it has
# moved on, so it never imports from live modules or from its siblings. Six
# duplicated lines are the price of that, and the cheap half of the bargain.
def _in_list(values: tuple[str, ...]) -> str:
return "task_kind IN (" + ", ".join(f"'{v}'" for v in values) + ")"
def upgrade() -> None:
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
op.create_check_constraint(
"notes_task_kind_check", "notes", _in_list(_KINDS_AFTER),
)
def downgrade() -> None:
# Any row already filed as a spike would violate the narrowed constraint,
# so they are demoted to 'work' first. Lossy and deliberately so: the
# alternative is a downgrade that fails on real data, which is worse than
# a downgrade that says what it did.
op.execute("UPDATE notes SET task_kind = 'work' WHERE task_kind = 'spike'")
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
op.create_check_constraint(
"notes_task_kind_check", "notes", _in_list(_KINDS_BEFORE),
)
+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
+81
View File
@@ -0,0 +1,81 @@
/**
* Canonical systems — the GLOBAL area vocabulary every project's Systems can
* map onto (milestone 307).
*
* The mapping is an ASSOCIATION, never a rename: a project's System keeps the
* name the project gave it, and `canonical_id` only records which shared area
* it is an instance of. An unmapped System is fully usable — the catalog is a
* convergence aid, not a gate.
*/
import { apiGet, apiPost, apiPatch, apiPut } from "@/api/client";
export interface CanonicalSystem {
id: number;
name: string;
/** The match key: lowercase, "&" folded to "and", punctuation collapsed. */
slug: string;
description: string | null;
order_index: number;
created_at: string | null;
updated_at: string | null;
}
/**
* A suggested mapping. `basis` is the whole point of showing it:
* - `exact` — the names differ only in spelling. Mechanical.
* - `overlap` — they share a meaningful word. A judgment call the reviewer is
* making, and it must never be presented as if it were the first.
*/
export interface CanonicalMatch {
id: number;
name: string;
basis: "exact" | "overlap";
score?: number;
}
export interface MappingProposal {
system_id: number;
system_name: string;
canonical_id: number;
canonical_name: string;
basis: "exact" | "overlap";
score: number;
}
export async function listCanonicalSystems(): Promise<CanonicalSystem[]> {
const data = await apiGet<{ canonical_systems: CanonicalSystem[] }>(
"/api/canonical-systems",
);
return data.canonical_systems;
}
/** Admin only — a global list anyone can extend stops being shared. */
export async function createCanonicalSystem(data: {
name: string;
description?: string;
}): Promise<CanonicalSystem> {
return apiPost("/api/canonical-systems", data);
}
export async function updateCanonicalSystem(
id: number,
data: Partial<{ name: string; description: string; order_index: number }>,
): Promise<CanonicalSystem> {
return apiPatch(`/api/canonical-systems/${id}`, data);
}
/** Proposals for a project's UNMAPPED Systems. Reads only — nothing applied. */
export async function proposeMappings(projectId: number): Promise<MappingProposal[]> {
const data = await apiGet<{ proposals: MappingProposal[] }>(
`/api/projects/${projectId}/canonical-proposals`,
);
return data.proposals;
}
/** Apply or clear one mapping. `null` unmaps. */
export async function mapSystem(
systemId: number,
canonicalId: number | null,
): Promise<{ id: number; canonical_id: number | null }> {
return apiPut(`/api/systems/${systemId}/canonical`, { canonical_id: canonicalId });
}
+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 });
+170 -14
View File
@@ -1,5 +1,24 @@
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
/** How a rule reaches a session (milestone 307). */
export type RuleTier = "always_on" | "conditional";
/**
* A typed edge between two rules. Each kind exists because its absence forced
* a workaround: merging two rules into one row, writing an override as a
* near-copy, or leaving a local addendum with nothing to say it is one.
*/
export type RuleRelationKind = "co_surfaces" | "overrides" | "elaborates";
export interface RuleRelation {
id: number;
kind: RuleRelationKind;
/** The rule at the OTHER end. */
rule_id: number;
direction: "outgoing" | "incoming";
note: string;
}
export interface Rulebook {
id: number;
owner_user_id: number;
@@ -26,35 +45,69 @@ export interface Rule {
project_id: number | null;
title: string;
statement: string;
/** WHEN this rule fires — the trigger, not the instruction. */
when_to_apply: string;
/**
* always_on preloads into every session; conditional is reachable and
* surfaced when its trigger fires. A rule with no tier set behaves as
* always_on, which is how every rule behaved before this existed.
*/
tier: RuleTier;
why: string;
how_to_apply: string;
/**
* How to check the rule is still true, and the state that ends it. Set
* only on a rule that asserts a fact about something outside the
* operator's control; empty on a rule that is a decision, which is most
* of them. Empty is meaningful, not missing.
*/
verify_with: string;
expires_when: string;
/** When the check last passed. Null means never checked. */
verified_at: string | null;
/** The note or task that caused this rule, if one was recorded. */
arose_from_id: number | null;
order_index: number;
created_at: string | null;
updated_at: string | null;
/** Present only when the rule has them (the server omits empty keys). */
systems?: { id: number; name: string }[];
relations?: RuleRelation[];
}
/**
* A rule as a LIST ROW — services.rulebooks.rule_brief's output. Carries the
* age deliberately: a rule written before the capability it duplicates is
* otherwise indistinguishable, at a glance, from one still doing work.
*/
export interface RuleHeader {
id: number;
title: string;
statement: string;
topic_id: number | null;
tier: RuleTier;
/** A date (YYYY-MM-DD), not a timestamp. */
updated_at: string | null;
when_to_apply?: string;
arose_from_id?: number;
/**
* Present ONLY on a rule that carries a check — the presence of the key
* is itself the signal that this rule asserts a fact that can go false.
* A date (YYYY-MM-DD), or the literal "never".
*/
last_verified?: string;
}
export interface ApplicableRules {
rules: {
id: number;
title: string;
statement: string;
topic_id: number;
// Both lists are rule_brief's output — the SAME builder, so they are
// described the same way here rather than as two hand-written shapes that
// drift from it and from each other (which is what the server side had).
rules: (RuleHeader & {
topic_title: string;
rulebook_id: number;
rulebook_title: string;
}[];
project_rules: {
id: number;
title: string;
statement: string;
}[];
})[];
project_rules: RuleHeader[];
suppressed_rules: {
id: number;
title: string;
@@ -71,6 +124,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 ───────────────────────────────────────────────────────
@@ -131,14 +186,48 @@ export async function getRule(id: number): Promise<Rule> {
return apiGet(`/api/rules/${id}`);
}
export async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string; order_index?: number }): Promise<Rule> {
/**
* The fields both write paths accept. `system_ids` REPLACES a rule's areas.
*
* Sending "" for a nullable text field CLEARS it here — the server maps an
* empty string to NULL, so an emptied form input does what it looks like it
* does. (The MCP door reads "" as "leave unchanged" and needs an explicit
* clear_fields list instead; the two idioms reach the same state.)
*/
export interface RuleWrite {
title: string;
statement: string;
when_to_apply: string;
tier: RuleTier;
why: string;
how_to_apply: string;
order_index: number;
system_ids: number[];
arose_from_id: number | null;
verify_with: string;
expires_when: string;
}
export async function createRule(topicId: number, data: Partial<RuleWrite> & { title: string; statement: string }): Promise<Rule> {
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
}
export async function updateRule(id: number, data: Partial<{ title: string; statement: string; why: string; how_to_apply: string; order_index: number }>): Promise<Rule> {
export async function updateRule(id: number, data: Partial<RuleWrite>): Promise<Rule> {
return apiPatch(`/api/rules/${id}`, data);
}
/** Draw a typed edge from one rule to another. Idempotent. */
export async function relateRules(
fromRuleId: number,
data: { to_rule_id: number; kind: RuleRelationKind; note?: string },
): Promise<{ id: number }> {
return apiPost(`/api/rules/${fromRuleId}/relations`, data);
}
export async function unrelateRules(relationId: number): Promise<void> {
return apiDelete(`/api/rule-relations/${relationId}`);
}
export async function deleteRule(id: number): Promise<void> {
return apiDelete(`/api/rules/${id}`);
}
@@ -159,7 +248,7 @@ export async function getProjectApplicableRules(projectId: number): Promise<Appl
export async function createProjectRule(
projectId: number,
data: { statement: string; title?: string; why?: string; how_to_apply?: string },
data: Partial<RuleWrite> & { statement: string },
): Promise<Rule> {
return apiPost(`/api/projects/${projectId}/rules`, data);
}
@@ -181,3 +270,70 @@ 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}`);
}
/**
* One row of the staleness sweep. Unlike RuleHeader this carries the CHECK
* in full — the reader is about to go and run it, so the text is the point
* of the payload rather than the bloat a listing avoids.
*/
export interface RuleVerificationRow {
id: number;
title: string;
statement: string;
tier: RuleTier;
topic_id: number | null;
project_id: number | null;
when_to_apply: string;
verify_with: string;
expires_when: string;
/** A date (YYYY-MM-DD), or the literal "never". */
last_verified: string | null;
/** Null when never verified — "never" is not zero days ago. */
days_since_verified: number | null;
}
/**
* Rules asserting a fact that may have gone false, oldest verification
* first, never-checked at the top. Rules without a check never appear:
* they are decisions, and there is nothing to go and check.
*
* Not filterable by project — a project reaches rules through project
* scope, subscriptions, always-on rulebooks and exclusions, and a filter
* missing one of those paths would under-report.
*/
export async function listRulesDueForVerification(opts: {
olderThanDays?: number;
tier?: RuleTier;
neverOnly?: boolean;
} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> {
const q = new URLSearchParams();
if (opts.olderThanDays) q.set("older_than_days", String(opts.olderThanDays));
if (opts.tier) q.set("tier", opts.tier);
if (opts.neverOnly) q.set("never_only", "true");
const qs = q.toString();
return apiGet(`/api/rules-due-for-verification${qs ? `?${qs}` : ""}`);
}
/**
* Record that a rule's check was RUN, and what it said.
*
* `stillTrue: false` writes nothing on purpose — a rule whose check failed
* is not in a recordable state, it is wrong — so it stays at the top of the
* sweep until someone corrects or retires it.
*/
export async function markRuleVerified(
id: number, stillTrue = true,
): Promise<Rule & { verified: boolean }> {
return apiPost(`/api/rules/${id}/verify`, { still_true: stillTrue });
}
+21 -2
View File
@@ -1,9 +1,15 @@
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
import type { CanonicalMatch } from "@/api/canonicalSystems";
export interface System {
id: number;
project_id: number;
name: string;
/**
* The global area this System is an instance of, or null. Null is a valid
* resting state — a project-specific area should stay unmapped.
*/
canonical_id: number | null;
description: string;
color: string | null;
status: "active" | "archived";
@@ -18,10 +24,23 @@ export async function listSystems(projectId: number): Promise<System[]> {
return data.systems;
}
/**
* A created System, plus the catalog's answer about its name. An `exact`
* catalog hit is applied by the server and arrives as a populated
* `canonical_id`; an `overlap` is only OFFERED, and comes back here for the
* caller to accept or ignore.
*
* A same-named System in this project is a 409 ApiError carrying
* `{duplicate, existing_id}` — the same gate the MCP door enforces (#2482).
*/
export interface CreatedSystem extends System {
canonical_suggestion?: CanonicalMatch;
}
export async function createSystem(
projectId: number,
data: { name: string; description?: string; color?: string },
): Promise<System> {
data: { name: string; description?: string; color?: string; canonical_id?: number },
): Promise<CreatedSystem> {
return apiPost(`/api/projects/${projectId}/systems`, data);
}
+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;
}
+33
View File
@@ -0,0 +1,33 @@
/* Shared by the rules panes (RulebookListPane, RuleListPane,
RulebookDetailPane, RuleSweepPane): the pane surface, its heading, and the
title chip. Counting them in this comment went stale the first time a
fourth was added, so it no longer does. 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; }
/* A small marker beside a rule's title. Two of these appeared within one
milestone (tier, then verification) and were byte-identical; a third would
have drifted. The pane's italic serif title is inherited by anything inside
it, so the chip resets family and style explicitly. */
.rule-chip {
margin-left: 0.4rem;
font-family: var(--fs-font-body);
font-style: normal;
font-size: 0.62rem;
color: var(--fs-text-secondary);
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-pill);
padding: 0.05rem 0.4rem;
vertical-align: middle;
}
+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;
+263 -23
View File
@@ -1,14 +1,18 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useSystemsStore } from "@/stores/systems";
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
import { useToastStore } from "@/stores/toast";
import { getProjectIssues } from "@/api/systems";
import type { System, TaskLike } from "@/api/systems";
import type { CanonicalMatch } from "@/api/canonicalSystems";
import { apiErrorMessage } from "@/api/client";
import { Pencil, Trash2, Archive, ArchiveRestore } from "lucide-vue-next";
const props = defineProps<{ projectId: number }>();
const store = useSystemsStore();
const canon = useCanonicalSystemsStore();
const toast = useToastStore();
const error = ref<string | null>(null);
@@ -19,14 +23,26 @@ const issues = ref<TaskLike[]>([]);
const showCreate = ref(false);
const newName = ref("");
const newDescription = ref("");
// The global area, chosen explicitly. A PICKER rather than a live matcher on
// purpose: reproducing the server's slug rule in TypeScript would give this
// feature two matchers to keep in step, which is the exact drift the catalog
// exists to end. The server still applies an exact hit on submit.
const newCanonicalId = ref<number | null>(null);
const creating = ref(false);
// An `overlap` the server offered after a create — an offer, never applied.
const suggestion = ref<{ systemId: number; match: CanonicalMatch } | null>(null);
// Edit state
const editingId = ref<number | null>(null);
const editName = ref("");
const editDescription = ref("");
const editCanonicalId = ref<number | null>(null);
const savingEdit = ref(false);
// Mapping review
const showReview = ref(false);
const reviewBusy = ref<number | null>(null);
// Delete confirmation
const deletingSystem = ref<System | null>(null);
@@ -37,6 +53,12 @@ const visibleSystems = computed(() =>
showArchived.value ? systems.value : activeSystems.value,
);
const proposals = computed(() => canon.proposalsByProject[props.projectId] ?? []);
function areaName(system: System): string | null {
return canon.byId(system.canonical_id)?.name ?? null;
}
async function load() {
error.value = null;
try {
@@ -49,6 +71,14 @@ async function load() {
} catch {
issues.value = [];
}
// Both fail soft: the catalog is a naming aid, and a review prompt that
// cannot load must not take the Systems list down with it.
await canon.fetchCatalog();
try {
await canon.fetchProposals(props.projectId);
} catch {
/* no proposals shown */
}
}
onMounted(load);
@@ -58,12 +88,14 @@ function openCreate() {
showCreate.value = true;
newName.value = "";
newDescription.value = "";
newCanonicalId.value = null;
}
function cancelCreate() {
showCreate.value = false;
newName.value = "";
newDescription.value = "";
newCanonicalId.value = null;
}
async function submitCreate() {
@@ -71,23 +103,60 @@ async function submitCreate() {
if (!name || creating.value) return;
creating.value = true;
try {
await store.createSystem(props.projectId, {
const created = await store.createSystem(props.projectId, {
name,
description: newDescription.value.trim() || undefined,
canonical_id: newCanonicalId.value ?? undefined,
});
cancelCreate();
toast.show("System created");
} catch {
toast.show("Failed to create system", "error");
if (created.canonical_suggestion) {
// An overlap: shown as an offer beside the new System, never applied.
suggestion.value = { systemId: created.id, match: created.canonical_suggestion };
}
toast.show(
created.canonical_id
? `System created and filed under ${canon.byId(created.canonical_id)?.name}`
: "System created",
);
} catch (e) {
// 409 = this project already has that System. Say WHICH one, so the
// answer is actionable rather than "it didn't work".
toast.show(apiErrorMessage(e, "Failed to create system"), "error");
} finally {
creating.value = false;
}
}
async function acceptSuggestion() {
const pending = suggestion.value;
if (!pending) return;
suggestion.value = null;
try {
await canon.mapSystem(props.projectId, pending.systemId, pending.match.id);
await store.fetchSystems(props.projectId);
toast.show(`Filed under ${pending.match.name}`);
} catch {
/* the store already reported it */
}
}
async function applyProposal(systemId: number, canonicalId: number) {
reviewBusy.value = systemId;
try {
await canon.mapSystem(props.projectId, systemId, canonicalId);
await store.fetchSystems(props.projectId);
} catch {
/* the store already reported it */
} finally {
reviewBusy.value = null;
}
}
function startEdit(system: System) {
editingId.value = system.id;
editName.value = system.name;
editDescription.value = system.description;
editCanonicalId.value = system.canonical_id;
}
function cancelEdit() {
@@ -103,6 +172,12 @@ async function submitEdit(system: System) {
name,
description: editDescription.value.trim(),
});
// The mapping is a separate write with its own validation — one column,
// one writer (services/canonical_systems.set_system_canonical).
if (editCanonicalId.value !== system.canonical_id) {
await canon.mapSystem(props.projectId, system.id, editCanonicalId.value);
await store.fetchSystems(props.projectId);
}
editingId.value = null;
toast.show("System updated");
} catch {
@@ -161,6 +236,65 @@ async function confirmDelete() {
</ul>
</div>
<!-- Mapping review. Only appears when there is something to decide, and
it says HOW MANY rather than nagging with a permanent banner. -->
<div v-if="proposals.length" class="area-review">
<button class="area-review-head" @click="showReview = !showReview">
<span class="area-review-count">{{ proposals.length }}</span>
{{ proposals.length === 1 ? "system" : "systems" }} may belong to a shared area
<span class="area-review-chev">{{ showReview ? "▾" : "▸" }}</span>
</button>
<ul v-if="showReview" class="area-proposals">
<li v-for="p in proposals" :key="p.system_id" class="area-proposal">
<div class="area-proposal-text">
<span class="area-proposal-name">{{ p.system_name }}</span>
<span class="area-proposal-arrow" aria-hidden="true"></span>
<span class="area-proposal-target">{{ p.canonical_name }}</span>
<!-- The basis is the decision the reviewer is making: `exact`
differs only in spelling, `overlap` is a judgment call.
Showing them identically is how a wrong mapping is waved
through, so they never share a style. -->
<span
class="area-basis"
:class="p.basis === 'exact' ? 'area-basis--exact' : 'area-basis--overlap'"
:title="
p.basis === 'exact'
? 'Same name up to spelling — safe to accept.'
: 'Shares a word. Accept only if it is really the same area.'
"
>{{ p.basis === "exact" ? "same name" : "similar" }}</span>
</div>
<div class="area-proposal-actions">
<button
class="btn-primary btn-compact"
:disabled="reviewBusy === p.system_id"
@click="applyProposal(p.system_id, p.canonical_id)"
>
{{ reviewBusy === p.system_id ? "Filing…" : "File here" }}
</button>
<button
class="btn-ghost btn-compact"
@click="canon.dismissProposal(props.projectId, p.system_id)"
>
Not this
</button>
</div>
</li>
</ul>
</div>
<!-- An overlap offered by the server after a create. Never applied. -->
<div v-if="suggestion" class="area-offer">
<span>
Is this the same area as
<strong>{{ suggestion.match.name }}</strong>?
</span>
<div class="area-proposal-actions">
<button class="btn-primary btn-compact" @click="acceptSuggestion">File it there</button>
<button class="btn-ghost btn-compact" @click="suggestion = null">No, it's ours</button>
</div>
</div>
<!-- Toolbar -->
<div class="systems-toolbar">
<button v-if="!showCreate" class="btn-ghost btn-inline btn-add-system" @click="openCreate">
@@ -176,7 +310,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,11 +318,25 @@ 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"
></textarea>
<label v-if="canon.catalog.length" class="area-field">
<span class="area-label">Shared area</span>
<select v-model="newCanonicalId" class="fs-input area-select" aria-label="Shared area">
<option :value="null">None specific to this project</option>
<option v-for="entry in canon.catalog" :key="entry.id" :value="entry.id">
{{ entry.name }}
</option>
</select>
<!-- .field-hint is the shared hint class beside .fs-input
(components.css) not restated scoped. -->
<span class="field-hint">
Files this system under an area shared by every project. Your name stays as you typed it.
</span>
</label>
<div class="system-form-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
{{ creating ? "Creating…" : "Create" }}
@@ -227,7 +375,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,11 +383,20 @@ async function confirmDelete() {
/>
<textarea
v-model="editDescription"
class="system-textarea"
class="fs-input system-textarea"
rows="2"
placeholder="Description (optional)"
aria-label="System description"
></textarea>
<label v-if="canon.catalog.length" class="area-field">
<span class="area-label">Shared area</span>
<select v-model="editCanonicalId" class="fs-input area-select" aria-label="Shared area">
<option :value="null">None specific to this project</option>
<option v-for="entry in canon.catalog" :key="entry.id" :value="entry.id">
{{ entry.name }}
</option>
</select>
</label>
<div class="system-form-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
{{ savingEdit ? "Saving…" : "Save" }}
@@ -264,6 +421,13 @@ async function confirmDelete() {
:title="`${system.open_issue_count} open issue(s)`"
>{{ system.open_issue_count }} open</span>
<span v-if="system.status === 'archived'" class="archived-badge">Archived</span>
<!-- Not a TagPill: that recipe prefixes "#" and means a tag.
This is the shared AREA this system is an instance of. -->
<span
v-if="areaName(system)"
class="area-chip"
:title="`Filed under the shared area “${areaName(system)}” — records and rules about this area line up across projects.`"
>{{ areaName(system) }}</span>
</div>
<p v-if="system.description" class="system-description">{{ system.description }}</p>
</div>
@@ -335,6 +499,91 @@ async function confirmDelete() {
.issue-systems { display: flex; gap: 0.25rem; flex-shrink: 0; flex-wrap: wrap; }
.issue-sys-chip { font-size: 0.66rem; color: var(--fs-text-secondary); background: var(--fs-surface-raised); border-radius: 999px; padding: 0.05rem 0.4rem; }
/* ── Shared-area mapping (milestone 307) ──────────────────────────
The review is a disclosure, not a banner: it exists only while there is
something to decide, and collapses to one line until opened. */
.area-review {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
background: var(--fs-surface-raised);
}
.area-review-head {
display: flex;
align-items: center;
gap: var(--fs-space-2);
width: 100%;
padding: var(--fs-space-3);
background: none;
border: none;
color: var(--fs-text-secondary);
font: inherit;
font-size: 0.82rem;
text-align: left;
cursor: pointer;
border-radius: var(--fs-radius-lg);
}
.area-review-head:hover { color: var(--fs-text-primary); }
.area-review-head:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
.area-review-count {
background: var(--fs-accent-soft);
color: var(--fs-accent);
border-radius: var(--fs-radius-pill);
padding: 0.05rem 0.45rem;
font-variant-numeric: tabular-nums;
}
.area-review-chev { margin-left: auto; color: var(--fs-text-tertiary); }
.area-proposals { list-style: none; margin: 0; padding: 0 var(--fs-space-3) var(--fs-space-3); display: flex; flex-direction: column; gap: var(--fs-space-2); }
.area-proposal {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fs-space-3);
flex-wrap: wrap;
padding: var(--fs-space-2) var(--fs-space-3);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
.area-proposal-text { display: flex; align-items: center; gap: var(--fs-space-2); flex-wrap: wrap; font-size: 0.85rem; min-width: 0; }
.area-proposal-name { color: var(--fs-text-primary); }
.area-proposal-arrow { color: var(--fs-text-tertiary); }
.area-proposal-target { color: var(--fs-accent); }
.area-proposal-actions { display: flex; gap: var(--fs-space-2); flex-shrink: 0; }
/* The two bases must never look alike — one is mechanical, the other is the
reviewer's judgment, and that difference is the whole decision. */
.area-basis { font-size: 0.68rem; border-radius: var(--fs-radius-sm); padding: 0.05rem 0.4rem; }
.area-basis--exact { background: var(--fs-status-done-bg); color: var(--fs-status-done); }
.area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); }
.area-offer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--fs-space-3);
flex-wrap: wrap;
padding: var(--fs-space-3);
font-size: 0.85rem;
color: var(--fs-text-secondary);
background: var(--fs-accent-faint);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
}
.area-field { display: flex; flex-direction: column; gap: 0.3rem; }
.area-label { font-size: 0.78rem; color: var(--fs-text-tertiary); }
.area-select { box-sizing: border-box; width: 100%; }
.area-chip {
font-size: 0.66rem;
color: var(--fs-accent);
background: var(--fs-accent-soft);
border-radius: var(--fs-radius-pill);
padding: 0.05rem 0.45rem;
white-space: nowrap;
}
/* ── Toolbar ──────────────────────────────────────────────────── */
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
.btn-add-system {
@@ -372,18 +621,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 +733,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";
@@ -16,10 +24,16 @@ const allRulebooks = ref<Rulebook[]>([]);
const showPicker = ref(false);
const expandedRuleIds = ref<Set<number>>(new Set());
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
const ruleDetails = ref<Record<number, {
why: string; how_to_apply: string;
verify_with: string; expires_when: string; verified_at: string | null;
}>>({});
const showProjectRuleForm = ref(false);
const newProjectRule = ref({ title: "", statement: "", why: "", how_to_apply: "" });
const newProjectRule = ref({
title: "", statement: "", why: "", how_to_apply: "",
when_to_apply: "", tier: "always_on" as "always_on" | "conditional",
});
async function load() {
applicable.value = await getProjectApplicableRules(props.projectId);
@@ -35,6 +49,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);
@@ -51,6 +70,9 @@ async function toggleRuleExpand(ruleId: number) {
ruleDetails.value[ruleId] = {
why: rule.why || "",
how_to_apply: rule.how_to_apply || "",
verify_with: rule.verify_with || "",
expires_when: rule.expires_when || "",
verified_at: rule.verified_at,
};
}
}
@@ -58,6 +80,11 @@ async function toggleRuleExpand(ruleId: number) {
expandedRuleIds.value = new Set(expandedRuleIds.value);
}
/** "never run" reads as a stronger claim than an absent date — and it is. */
function checkAge(verifiedAt: string | null): string {
return verifiedAt ? `last passed ${verifiedAt.slice(0, 10)}` : "never run";
}
function openInRulesView(rulebookId: number, ruleId?: number) {
const query: Record<string, string> = { rb: String(rulebookId) };
if (ruleId) query.rule = String(ruleId);
@@ -77,14 +104,20 @@ interface RulebookGroup {
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
const byRulebook = new Map<number, RulebookGroup>();
for (const r of rules) {
// A rule carries topic_id XOR project_id. Only rulebook-scoped rules reach
// this list, so a null topic would be a server-side contradiction — skip
// it rather than widen the group's type to accommodate a case that means
// something is wrong upstream.
if (r.topic_id === null) continue;
const topicId = r.topic_id;
let rb = byRulebook.get(r.rulebook_id);
if (!rb) {
rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] };
byRulebook.set(r.rulebook_id, rb);
}
let topic = rb.topics.find((t) => t.topic_id === r.topic_id);
let topic = rb.topics.find((t) => t.topic_id === topicId);
if (!topic) {
topic = { topic_id: r.topic_id, topic_title: r.topic_title, rules: [] };
topic = { topic_id: topicId, topic_title: r.topic_title, rules: [] };
rb.topics.push(topic);
}
topic.rules.push(r);
@@ -100,8 +133,13 @@ async function submitProjectRule() {
title: newProjectRule.value.title.trim() || undefined,
why: newProjectRule.value.why.trim() || undefined,
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
when_to_apply: newProjectRule.value.when_to_apply.trim() || undefined,
tier: newProjectRule.value.tier,
});
newProjectRule.value = { title: "", statement: "", why: "", how_to_apply: "" };
newProjectRule.value = {
title: "", statement: "", why: "", how_to_apply: "",
when_to_apply: "", tier: "always_on",
};
showProjectRuleForm.value = false;
await load();
}
@@ -172,6 +210,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>
@@ -195,6 +244,24 @@ watch(() => props.projectId, load);
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
rows="2"
></textarea>
<textarea
v-model="newProjectRule.when_to_apply"
placeholder="When to apply — the trigger, not the instruction"
rows="2"
></textarea>
<div class="tier-row">
<label>
<input v-model="newProjectRule.tier" type="radio" value="always_on" />
Always on
</label>
<label>
<input v-model="newProjectRule.tier" type="radio" value="conditional" />
Conditional
</label>
<span class="tier-hint">
Conditional if you had to name a system, an artifact or a moment to state the trigger.
</span>
</div>
<textarea
v-model="newProjectRule.why"
placeholder="Why (optional) — the rationale"
@@ -223,6 +290,16 @@ watch(() => props.projectId, load);
<div v-if="ruleDetails[r.id].how_to_apply">
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
</div>
<!-- Shown only when the rule carries a check. Read-only here: this
tab is the project's view of what binds it, and editing a rule
belongs on the rulebook surface that owns it. -->
<div v-if="ruleDetails[r.id].verify_with">
<strong>Check:</strong> {{ ruleDetails[r.id].verify_with }}
<span class="rule-check-age">{{ checkAge(ruleDetails[r.id].verified_at) }}</span>
</div>
<div v-if="ruleDetails[r.id].expires_when">
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
</div>
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
</div>
</li>
@@ -276,6 +353,13 @@ watch(() => props.projectId, load);
<div v-if="ruleDetails[r.id].how_to_apply">
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
</div>
<div v-if="ruleDetails[r.id].verify_with">
<strong>Check:</strong> {{ ruleDetails[r.id].verify_with }}
<span class="rule-check-age">{{ checkAge(ruleDetails[r.id].verified_at) }}</span>
</div>
<div v-if="ruleDetails[r.id].expires_when">
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
</div>
<button
class="edit-link"
@click="openInRulesView(r.rulebook_id, r.id)"
@@ -321,6 +405,14 @@ watch(() => props.projectId, load);
</template>
<style scoped>
.tier-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.85rem; }
.tier-row label { display: inline-flex; align-items: center; gap: 0.3rem; }
.tier-row input { accent-color: var(--fs-accent); }
.tier-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); }
.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;
@@ -364,6 +456,11 @@ ul { list-style: none; padding: 0; margin: 0; }
}
.rule-head { cursor: pointer; }
.rule-title { font-weight: 500; }
.rule-check-age {
margin-left: var(--fs-space-2);
color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
}
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
.rule-detail {
margin-top: 0.5rem; padding: 0.5rem;
@@ -1,18 +1,66 @@
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import { computed, ref, watch, onMounted } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks";
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
import type { RuleTier } from "@/api/rulebooks";
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
const emit = defineEmits<{ close: [] }>();
const store = useRulebooksStore();
const canon = useCanonicalSystemsStore();
const title = ref("");
const statement = ref("");
const whenToApply = ref("");
const tier = ref<RuleTier>("always_on");
const systemIds = ref<number[]>([]);
const why = ref("");
const howToApply = ref("");
const verifyWith = ref("");
const expiresWhen = ref("");
const relations = computed(() => store.currentRule?.relations ?? []);
// The label a reader needs to judge an edge, not the stored token.
const RELATION_LABEL: Record<string, { outgoing: string; incoming: string }> = {
co_surfaces: { outgoing: "arrives with", incoming: "arrives with" },
overrides: { outgoing: "overrides", incoming: "is overridden by" },
elaborates: { outgoing: "elaborates", incoming: "is elaborated by" },
};
function relationLabel(kind: string, direction: "outgoing" | "incoming") {
return RELATION_LABEL[kind]?.[direction] ?? kind;
}
function toggleSystem(id: number) {
const at = systemIds.value.indexOf(id);
if (at >= 0) systemIds.value.splice(at, 1);
else systemIds.value.push(id);
}
const isCreating = ref(props.ruleId === null);
// The stored stamp, not the draft: it describes the check that was RUN, and
// an unsaved edit to the textarea has not been run against anything.
const verifiedAt = computed(() => store.currentRule?.verified_at ?? null);
const savedCheck = computed(() => store.currentRule?.verify_with ?? "");
// Built here rather than in the template: same shape as the server's
// last_verified_label, and it keeps the null-narrowing in TypeScript's reach.
const stampLabel = computed(() =>
verifiedAt.value ? `Last checked ${verifiedAt.value.slice(0, 10)}` : "Never checked",
);
const verifying = ref(false);
async function verify(stillTrue: boolean) {
if (props.ruleId === null) return;
verifying.value = true;
try {
await store.verifyRule(props.ruleId, stillTrue);
} finally {
verifying.value = false;
}
}
async function load() {
if (props.ruleId !== null) {
await store.fetchRule(props.ruleId);
@@ -20,15 +68,26 @@ async function load() {
if (r) {
title.value = r.title;
statement.value = r.statement;
whenToApply.value = r.when_to_apply || "";
tier.value = r.tier || "always_on";
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
why.value = r.why || "";
howToApply.value = r.how_to_apply || "";
verifyWith.value = r.verify_with || "";
expiresWhen.value = r.expires_when || "";
}
} else {
title.value = "";
statement.value = "";
whenToApply.value = "";
tier.value = "always_on";
systemIds.value = [];
why.value = "";
howToApply.value = "";
verifyWith.value = "";
expiresWhen.value = "";
}
await canon.fetchCatalog();
}
async function save() {
@@ -36,16 +95,26 @@ async function save() {
emit("close");
return;
}
const fields = {
title: title.value,
statement: statement.value,
when_to_apply: whenToApply.value,
tier: tier.value,
// Always sent, so clearing the last area actually clears it — the server
// reads a list as "these ARE the areas now".
system_ids: systemIds.value,
why: why.value,
how_to_apply: howToApply.value,
// Always sent, including empty. The REST door maps "" to NULL, so
// clearing a field here actually clears it — the MCP door's "" means
// "leave unchanged" and needs an explicit clear_fields list instead.
verify_with: verifyWith.value,
expires_when: expiresWhen.value,
};
if (isCreating.value && props.topicId !== null) {
await store.createRule(props.topicId, {
title: title.value, statement: statement.value,
why: why.value, how_to_apply: howToApply.value,
});
await store.createRule(props.topicId, fields);
} else if (props.ruleId !== null) {
await store.updateRule(props.ruleId, {
title: title.value, statement: statement.value,
why: why.value, how_to_apply: howToApply.value,
});
await store.updateRule(props.ruleId, fields);
}
emit("close");
}
@@ -77,6 +146,108 @@ watch(() => props.ruleId, load);
Statement <span class="required">*</span>
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
</label>
<label>
When to apply
<textarea
v-model="whenToApply"
rows="2"
placeholder="The trigger, not the instruction — “before any git push”, “when a release is being cut”."
/>
</label>
<fieldset class="tier">
<legend>How it reaches a session</legend>
<label class="tier-opt">
<input v-model="tier" type="radio" value="always_on" />
<span>
<strong>Always on</strong>
loaded into every session.
</span>
</label>
<label class="tier-opt">
<input v-model="tier" type="radio" value="conditional" />
<span>
<strong>Conditional</strong>
arrives when its trigger fires.
</span>
</label>
<p class="tier-test">
The test: can you name the trigger <em>without</em> naming a system, an artifact type
or a moment? If the honest answer is whenever you are working, it is always on.
Conditional costs nothing when it is irrelevant, which is what lets it be as long as
it needs to be.
</p>
</fieldset>
<fieldset v-if="canon.catalog.length" class="areas">
<legend>Areas this rule is about</legend>
<label v-for="entry in canon.catalog" :key="entry.id" class="area-opt">
<input
type="checkbox"
:checked="systemIds.includes(entry.id)"
@change="toggleSystem(entry.id)"
/>
<span>{{ entry.name }}</span>
</label>
<p class="tier-test">
What lets this rule reach a project working in that area.
</p>
</fieldset>
<fieldset class="check">
<legend>Can this rule go stale?</legend>
<p class="tier-test intro">
Most rules are <em>decisions</em> they have no truth value and change only when you
change them. Leave this empty for those. Fill it in when the rule asserts a
<em>fact</em> about something outside your control, because those go false quietly.
</p>
<label>
How to check it is still true
<textarea
v-model="verifyWith"
rows="2"
placeholder="A command, a path, a query — something runnable beats prose."
/>
</label>
<label>
What would end it
<textarea
v-model="expiresWhen"
rows="2"
placeholder="A state, not a date — “when the runner can be given a bash shell”."
/>
</label>
<div v-if="savedCheck" class="stamp">
<span class="stamp-age" :class="{ unchecked: !verifiedAt }">{{ stampLabel }}</span>
<span class="stamp-actions">
<button type="button" :disabled="verifying" @click="verify(true)">Still true</button>
<button type="button" :disabled="verifying" @click="verify(false)">No longer true</button>
</span>
</div>
<p v-if="savedCheck" class="tier-test">
Record this after actually running the check, never on the strength of the rule
sounding plausible. No longer true deliberately stores nothing the rule is wrong,
not in a state worth recording, so it stays at the top of the sweep until you fix or
retire it.
</p>
</fieldset>
<section v-if="relations.length" class="relations">
<h3>Related rules</h3>
<ul>
<li v-for="rel in relations" :key="rel.id" class="relation">
<span class="relation-kind">{{ relationLabel(rel.kind, rel.direction) }}</span>
<span class="relation-target">rule #{{ rel.rule_id }}</span>
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
</li>
</ul>
<p class="tier-test">
Rules that <em>fail together</em> are linked, never merged a merged rule cannot be
cited, surfaced or suppressed a clause at a time.
</p>
</section>
<label>
Why
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
@@ -118,6 +289,48 @@ input, textarea {
padding: 0.5rem; font: inherit;
font-family: inherit;
}
fieldset { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: 0.75rem; margin-bottom: 1rem; }
legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary); }
.tier-opt, .area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
.tier-opt input, .area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
.tier-test { margin: 0.5rem 0 0; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
.relations h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
.relations ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
.relation { display: flex; align-items: baseline; gap: 0.4rem; flex-wrap: wrap; font-size: 0.85rem; }
.relation-kind { color: var(--fs-accent); }
.relation-target { color: var(--fs-text-primary); }
.relation-note { width: 100%; font-size: 0.78rem; color: var(--fs-text-tertiary); }
/* A real base rule, not just descendants: the dangling-style check reads a
class that only ever appears as an ancestor as a half-deleted rule, and it
is right to — an element whose appearance comes only from its tag is one
`fieldset {}` edit away from being unstyled. */
.check { margin-bottom: 1rem; }
.check .intro { margin-top: 0; margin-bottom: 0.75rem; }
.check label { margin-bottom: 0.75rem; }
.stamp {
display: flex; align-items: center; gap: var(--fs-space-2);
flex-wrap: wrap;
margin-top: 0.25rem;
}
.stamp-age { font-size: 0.8rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
/* Never-checked is INFORMATION, not an error: it is the ordinary starting
state of every constraint anyone has just written. --fs-overdue (error red)
is reserved for a broken promise like a missed due date; a verification age
is not one, and colouring it that way would make a brand-new rule look
broken. Secondary text, weighted normally. */
.stamp-age.unchecked { color: var(--fs-text-tertiary); font-style: italic; }
.stamp-actions { display: flex; gap: var(--fs-space-2); margin-left: auto; }
.stamp-actions button {
cursor: pointer; font: inherit; font-size: 0.78rem;
background: var(--fs-surface-raised); color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-sm);
padding: 0.2rem 0.55rem;
}
.stamp-actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
.stamp-actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
.trash:hover, .close:hover { opacity: 1; }
</style>
+32 -3
View File
@@ -13,17 +13,35 @@ const emit = defineEmits<{
<header><h2>Rules</h2></header>
<ul>
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
<div class="title">{{ r.title }}</div>
<div class="title">
{{ r.title }}
<!-- Only conditional is marked: always-on is the default and
badging every row would say nothing. -->
<span v-if="r.tier === 'conditional'" class="rule-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
<!-- Present only on a rule carrying a check, so the chip's very
presence says "this one asserts a fact that can go false". -->
<span
v-if="r.last_verified"
class="rule-chip check-chip"
:class="{ unchecked: r.last_verified === 'never' }"
:title="r.last_verified === 'never'
? 'Asserts a fact nobody has confirmed yet'
: `Check last passed ${r.last_verified}`"
>{{ r.last_verified === "never" ? "unverified" : `checked ${r.last_verified}` }}</span>
</div>
<div class="statement">{{ r.statement }}</div>
<div v-if="r.when_to_apply || r.updated_at" class="meta">
<span v-if="r.when_to_apply" class="trigger">{{ r.when_to_apply }}</span>
<span v-if="r.updated_at" class="age" :title="`Last changed ${r.updated_at}`">{{ r.updated_at }}</span>
</div>
</li>
</ul>
<button class="new-rule" @click="emit('create-rule', topicId)">+ New rule</button>
</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;
@@ -36,5 +54,16 @@ li {
li:hover { background: var(--fs-surface-hover); }
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
.meta { display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.35rem; font-size: 0.75em; }
.trigger { flex: 1; min-width: 0; color: var(--fs-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.age { color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; flex-shrink: 0; }
/* Only the departures from .rule-chip (rules-shared.css) live here. */
.check-chip { font-variant-numeric: tabular-nums; }
/* No age-graded colour on purpose. The sweep is already ordered by urgency, so
a red/amber ramp would restate the ordering AND require an invented "stale
after N days" threshold — a magic number nobody could defend and the first
thing to go out of date. Only "never" is marked, because it is categorically
different from a date rather than a worse one. */
.check-chip.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
.new-rule { cursor: pointer; }
</style>
@@ -0,0 +1,180 @@
<script setup lang="ts">
/**
* The staleness sweep: rules that assert a FACT, oldest verification first.
*
* Cross-cutting by nature — a rule that has gone false does not care which
* rulebook it sits in — so this is its own pane rather than a filter on the
* per-topic rule list. That list can only ever show one topic of one
* rulebook, so filtering it would quietly under-report, which is the exact
* failure this surface exists to catch.
*/
import { onMounted, ref } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks";
import type { RuleTier } from "@/api/rulebooks";
const emit = defineEmits<{ "open-rule": [id: number] }>();
const store = useRulebooksStore();
const neverOnly = ref(false);
const tier = ref<RuleTier | "">("");
const busyId = ref<number | null>(null);
function reload() {
return store.fetchRulesDue({
neverOnly: neverOnly.value || undefined,
tier: tier.value || undefined,
});
}
async function verify(id: number, stillTrue: boolean) {
busyId.value = id;
try {
await store.verifyRule(id, stillTrue);
} finally {
busyId.value = null;
}
}
onMounted(reload);
</script>
<template>
<section class="pane sweep">
<header>
<h2>Due for verification</h2>
<p class="lede">
Rules that assert a fact about something outside your control. Most rules are
decisions and never appear here they have no truth value to go stale.
</p>
</header>
<div class="filters">
<label class="filter">
<input v-model="neverOnly" type="checkbox" @change="reload" />
<span>Never checked only</span>
</label>
<label class="filter">
<span>Tier</span>
<select v-model="tier" @change="reload">
<option value="">any</option>
<option value="always_on">always on</option>
<option value="conditional">conditional</option>
</select>
</label>
</div>
<p v-if="store.loading" class="state">Loading</p>
<!-- An empty sweep is GOOD NEWS, and must not read like a broken page. -->
<p v-else-if="!store.rulesDue.length" class="state empty">
Nothing to check.
{{ neverOnly || tier ? "No rule matches these filters." : "No rule carries a check yet add one to a rule that asserts a fact." }}
</p>
<ol v-else class="rows">
<li v-for="r in store.rulesDue" :key="r.id" class="row">
<div class="row-head">
<button class="row-title" @click="emit('open-rule', r.id)">{{ r.title }}</button>
<span v-if="r.tier === 'always_on'" class="rule-chip" title="Loaded into every session — a wrong one is wrong everywhere at once">always on</span>
<span class="age" :class="{ unchecked: r.days_since_verified === null }">
{{ r.days_since_verified === null
? "never checked"
: `${r.days_since_verified}d ago` }}
</span>
</div>
<p class="statement">{{ r.statement }}</p>
<dl class="check">
<dt>Check</dt>
<dd><code>{{ r.verify_with }}</code></dd>
<template v-if="r.expires_when">
<dt>Ends when</dt>
<dd>{{ r.expires_when }}</dd>
</template>
</dl>
<div class="actions">
<button :disabled="busyId === r.id" @click="verify(r.id, true)">Still true</button>
<button :disabled="busyId === r.id" @click="verify(r.id, false)">No longer true</button>
</div>
</li>
</ol>
<p v-if="store.rulesDue.length" class="footnote">
Record a result only after actually running the check. No longer true stores nothing
on purpose the rule is wrong rather than in a state worth recording, so it keeps its
place here until you correct or retire it.
</p>
</section>
</template>
<style src="@/assets/rules-shared.css" />
<style scoped>
.sweep { display: flex; flex-direction: column; gap: var(--fs-space-3); }
.lede {
margin: 0;
max-width: 62ch;
font-size: 0.85rem;
color: var(--fs-text-secondary);
line-height: 1.5;
}
.filters { display: flex; gap: var(--fs-space-5); align-items: center; flex-wrap: wrap; }
.filter { display: flex; align-items: center; gap: var(--fs-space-2); font-size: 0.82rem; color: var(--fs-text-secondary); }
.filter input[type="checkbox"] { accent-color: var(--fs-accent); }
.filter select {
font: inherit; font-size: 0.82rem;
background: var(--fs-surface-page); color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
padding: 0.2rem 0.4rem;
}
.state { margin: 0; font-size: 0.9rem; color: var(--fs-text-secondary); }
.state.empty { color: var(--fs-text-tertiary); }
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-3); }
.row {
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-3);
}
.row-head { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
.row-title {
background: none; border: none; padding: 0; cursor: pointer;
font-family: Fraunces, serif; font-style: italic; font-size: 1.02rem;
color: var(--fs-text-primary); text-align: left;
}
.row-title:hover { text-decoration: underline; }
/* The ORDER carries urgency — the top of this list is the most overdue thing
in the rulebook. No red/amber ramp: it would restate the ordering and force
an invented "stale after N days" threshold. "Never" is marked because it is
categorically different from a date, not a worse one. */
.age { margin-left: auto; font-size: 0.78rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
.age.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
.statement { margin: 0.35rem 0 0; font-size: 0.88rem; color: var(--fs-text-secondary); }
.check { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem var(--fs-space-3); margin: var(--fs-space-3) 0 0; }
.check dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fs-text-tertiary); }
.check dd { margin: 0; font-size: 0.82rem; color: var(--fs-text-primary); min-width: 0; }
.check code {
font-family: var(--fs-font-mono);
background: var(--fs-surface-code-inline);
border-radius: var(--fs-radius-sm);
padding: 0.05rem 0.3rem;
overflow-wrap: anywhere;
}
.actions { display: flex; gap: var(--fs-space-2); margin-top: var(--fs-space-3); }
.actions button {
cursor: pointer; font: inherit; font-size: 0.78rem;
background: var(--fs-surface-page); color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
padding: 0.25rem 0.6rem;
}
.actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
.actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
.footnote { margin: 0; max-width: 62ch; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
</style>
@@ -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);
@@ -3,8 +3,8 @@ import { ref } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks";
import type { Rulebook } from "@/api/rulebooks";
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null }>();
const emit = defineEmits<{ select: [id: number] }>();
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null; sweepActive: boolean }>();
const emit = defineEmits<{ select: [id: number]; "select-sweep": [] }>();
const store = useRulebooksStore();
const isCreating = ref(false);
@@ -34,6 +34,18 @@ async function submitNew() {
<span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span>
</li>
</ul>
<!-- Not a rulebook, and deliberately below them: a cross-cutting view over
every rule the operator owns. It lives here because this is where you
come to look at rules, and a rule that has gone false belongs to no
one rulebook. -->
<button
class="sweep-entry"
:class="{ active: sweepActive }"
@click="emit('select-sweep')"
>
Due for verification
</button>
<div class="new-rulebook">
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
<form v-else @submit.prevent="submitNew">
@@ -47,9 +59,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); }
@@ -64,6 +75,15 @@ li:hover { background: var(--fs-surface-hover); }
color: var(--fs-text-on-action);
margin-left: auto;
}
.sweep-entry {
display: block; width: 100%; text-align: left;
margin-top: var(--fs-space-3);
padding: 0.5rem; border-radius: 6px;
background: none; border: 1px dashed var(--fs-border-color);
color: var(--fs-text-secondary); font: inherit; cursor: pointer;
}
.sweep-entry:hover { background: var(--fs-surface-hover); }
.sweep-entry.active { background: var(--fs-accent-soft); color: var(--fs-text-primary); }
.new-rulebook { margin-top: 1rem; }
.new-rulebook input {
width: 100%; margin-bottom: 0.5rem;
@@ -71,6 +91,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>
+93
View File
@@ -0,0 +1,93 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import * as api from "@/api/canonicalSystems";
import type { CanonicalSystem, MappingProposal } from "@/api/canonicalSystems";
import { useToastStore } from "@/stores/toast";
import { apiErrorMessage } from "@/api/client";
/**
* The global area catalog (milestone 307). Shared by every project, so it is
* fetched ONCE per session rather than per project — the whole point of the
* table is that it is the same list everywhere.
*/
export const useCanonicalSystemsStore = defineStore("canonicalSystems", () => {
const catalog = ref<CanonicalSystem[]>([]);
const loaded = ref(false);
const loading = ref(false);
const proposalsByProject = ref<Record<number, MappingProposal[]>>({});
async function fetchCatalog(force = false) {
if (loaded.value && !force) return catalog.value;
loading.value = true;
try {
catalog.value = await api.listCanonicalSystems();
loaded.value = true;
} catch {
// A naming aid must never break the screen it rides on — an empty
// catalog degrades the suggestion, it does not fail the form.
catalog.value = [];
} finally {
loading.value = false;
}
return catalog.value;
}
function byId(id: number | null): CanonicalSystem | undefined {
if (id == null) return undefined;
return catalog.value.find((c) => c.id === id);
}
async function fetchProposals(projectId: number) {
proposalsByProject.value[projectId] = await api.proposeMappings(projectId);
return proposalsByProject.value[projectId];
}
/** Apply or clear one mapping, then drop it from the pending proposals. */
async function mapSystem(projectId: number, systemId: number, canonicalId: number | null) {
try {
await api.mapSystem(systemId, canonicalId);
} catch (e) {
useToastStore().show(apiErrorMessage(e, "Failed to map system"), "error");
throw e;
}
dismissProposal(projectId, systemId);
}
/** Remove a proposal from the pending list without writing anything. */
function dismissProposal(projectId: number, systemId: number) {
const list = proposalsByProject.value[projectId];
if (list) {
proposalsByProject.value[projectId] = list.filter((p) => p.system_id !== systemId);
}
}
async function createEntry(data: { name: string; description?: string }) {
const entry = await api.createCanonicalSystem(data);
catalog.value.push(entry);
return entry;
}
async function updateEntry(
id: number,
data: Partial<{ name: string; description: string; order_index: number }>,
) {
const entry = await api.updateCanonicalSystem(id, data);
const idx = catalog.value.findIndex((c) => c.id === id);
if (idx >= 0) catalog.value[idx] = entry;
return entry;
}
return {
catalog,
loaded,
loading,
proposalsByProject,
fetchCatalog,
byId,
fetchProposals,
mapSystem,
dismissProposal,
createEntry,
updateEntry,
};
});
+89 -9
View File
@@ -9,6 +9,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
const topicsByRulebook = ref<Record<number, RulebookTopic[]>>({});
const rulesByTopic = ref<Record<number, RuleHeader[]>>({});
const currentRule = ref<Rule | null>(null);
const rulesDue = ref<api.RuleVerificationRow[]>([]);
// Kept so a verify re-reads the sweep with the SAME filters the operator is
// looking at — re-fetching unfiltered would silently widen the list under
// them at the moment they acted on it.
const lastSweepOpts = ref<{ olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean }>({});
const loading = ref(false);
async function fetchRulebooks() {
@@ -35,9 +40,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
async function fetchRules(topicId: number) {
try {
const rules = await api.listRules({ topic_id: topicId });
rulesByTopic.value[topicId] = rules.map((r) => ({
id: r.id, title: r.title, statement: r.statement, topic_id: r.topic_id,
}));
rulesByTopic.value[topicId] = rules.map(toHeader);
} catch (e) {
useToastStore().show("Failed to load rules", "error");
throw e;
@@ -98,24 +101,100 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
delete rulesByTopic.value[id];
}
async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string }) {
/**
* A list row built from a full rule. The row shape is the server's
* rule_brief, so every field it carries has to be mirrored here or the two
* disagree the moment a list is patched locally instead of re-fetched.
*/
function toHeader(rule: Rule): api.RuleHeader {
return {
id: rule.id,
title: rule.title,
statement: rule.statement,
topic_id: rule.topic_id,
tier: rule.tier,
updated_at: rule.updated_at,
when_to_apply: rule.when_to_apply || undefined,
arose_from_id: rule.arose_from_id ?? undefined,
// Mirrors services.rulebooks.last_verified_label: present ONLY when the
// rule carries a check, and "never" rather than absent when it has one
// nobody has run. Computed here so a row just written looks identical to
// the same row re-fetched, instead of losing its chip until a reload.
last_verified: rule.verify_with
? (rule.verified_at ? rule.verified_at.slice(0, 10) : "never")
: undefined,
};
}
async function createRule(topicId: number, data: Partial<api.RuleWrite> & { title: string; statement: string }) {
const rule = await api.createRule(topicId, data);
if (!rulesByTopic.value[topicId]) rulesByTopic.value[topicId] = [];
rulesByTopic.value[topicId].push({ id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id });
rulesByTopic.value[topicId].push(toHeader(rule));
return rule;
}
async function updateRule(id: number, data: Partial<Pick<Rule, "title" | "statement" | "why" | "how_to_apply" | "order_index">>) {
async function updateRule(id: number, data: Partial<api.RuleWrite>) {
const rule = await api.updateRule(id, data);
if (currentRule.value?.id === id) currentRule.value = rule;
for (const tid of Object.keys(rulesByTopic.value)) {
const list = rulesByTopic.value[Number(tid)];
const idx = list.findIndex((r) => r.id === id);
if (idx >= 0) list[idx] = { id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id };
if (idx >= 0) list[idx] = toHeader(rule);
}
return rule;
}
async function relateRules(
fromRuleId: number,
data: { to_rule_id: number; kind: api.RuleRelationKind; note?: string },
) {
await api.relateRules(fromRuleId, data);
// Re-read rather than patching locally: the edge reads from BOTH ends, so
// the far rule's relations changed too and a local splice would show only
// half of what just happened.
await fetchRule(fromRuleId);
}
async function unrelateRules(relationId: number, refreshRuleId: number) {
await api.unrelateRules(relationId);
await fetchRule(refreshRuleId);
}
/** The staleness sweep: rules asserting a fact, oldest verification first. */
async function fetchRulesDue(opts: {
olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean;
} = {}) {
loading.value = true;
lastSweepOpts.value = opts;
try {
const data = await api.listRulesDueForVerification(opts);
rulesDue.value = data.rules;
} finally {
loading.value = false;
}
}
/**
* Record that a rule's check was RUN, and what it said.
*
* A pass re-sorts the row to the back of the sweep, so the list is re-read
* rather than patched: the whole point of this surface is an ORDER, and a
* locally-mutated row would sit in its old position claiming a new date.
* A failure writes nothing server-side and the row keeps its place — also
* correct, and also what a re-read shows.
*/
async function verifyRule(id: number, stillTrue: boolean) {
const rule = await api.markRuleVerified(id, stillTrue);
if (currentRule.value?.id === id) currentRule.value = rule;
for (const tid of Object.keys(rulesByTopic.value)) {
const list = rulesByTopic.value[Number(tid)];
const idx = list.findIndex((r) => r.id === id);
if (idx >= 0) list[idx] = toHeader(rule);
}
if (rulesDue.value.length) await fetchRulesDue(lastSweepOpts.value);
return rule;
}
async function deleteRule(id: number) {
await api.deleteRule(id);
if (currentRule.value?.id === id) currentRule.value = null;
@@ -125,10 +204,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
}
return {
rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading,
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
createTopic, updateTopic, deleteTopic,
createRule, updateRule, deleteRule,
createRule, updateRule, deleteRule, relateRules, unrelateRules,
fetchRulesDue, verifyRule,
};
});
+1 -1
View File
@@ -22,7 +22,7 @@ export const useSystemsStore = defineStore("systems", () => {
async function createSystem(
projectId: number,
data: { name: string; description?: string; color?: string },
data: { name: string; description?: string; color?: string; canonical_id?: number },
) {
const system = await api.createSystem(projectId, data);
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
+3
View File
@@ -55,6 +55,9 @@ export const useTasksStore = defineStore("tasks", () => {
async function updateTask(
id: number,
// IssueFields carries `kind`, which the PATCH route now reads. It has
// always been SENT by the task editor; until #3129 the route dropped it
// and the save reported success while changing nothing.
data: Partial<
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
> & IssueFields
+10 -1
View File
@@ -2,7 +2,16 @@ import type { System } from "@/api/systems";
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
export type TaskPriority = "none" | "low" | "medium" | "high";
export type TaskKind = "work" | "plan" | "issue";
/**
* What KIND of work a task is, not how it is going.
* work — ships a change (default)
* issue — corrective; something was broken
* spike — time-boxed, output is knowledge; it succeeds by producing an
* answer and nothing ships at the end of it
* plan — retired (plans are milestones); kept so historical plan-tasks
* still render their kind
*/
export type TaskKind = "work" | "plan" | "issue" | "spike";
export type NoteType = "note" | "process" | "snippet";
export interface Note {
+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. */
+22 -3
View File
@@ -6,6 +6,7 @@ import RulebookListPane from "@/components/rules/RulebookListPane.vue";
import RulebookDetailPane from "@/components/rules/RulebookDetailPane.vue";
import RuleListPane from "@/components/rules/RuleListPane.vue";
import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue";
import RuleSweepPane from "@/components/rules/RuleSweepPane.vue";
const store = useRulebooksStore();
const route = useRoute();
@@ -15,6 +16,7 @@ const selectedRulebookId = ref<number | null>(null);
const selectedTopicId = ref<number | null>(null);
const editingRuleId = ref<number | null>(null);
const creatingRuleForTopic = ref<number | null>(null);
const sweepActive = ref(false);
function syncFromRoute() {
const rb = route.query.rb ? Number(route.query.rb) : null;
@@ -23,9 +25,20 @@ function syncFromRoute() {
selectedRulebookId.value = rb;
selectedTopicId.value = topic;
editingRuleId.value = rule;
sweepActive.value = route.query.view === "due";
}
function selectSweep() {
sweepActive.value = true;
// Keeps ?rule=… so the editor survives the mode switch, and drops the
// rulebook/topic selection the sweep does not use.
const { rb, topic, ...rest } = route.query;
void rb; void topic;
router.replace({ query: { ...rest, view: "due" } });
}
function selectRulebook(id: number) {
sweepActive.value = false;
selectedRulebookId.value = id;
selectedTopicId.value = null;
router.replace({ query: { rb: String(id) } });
@@ -70,10 +83,13 @@ watch(() => route.query, syncFromRoute);
<RulebookListPane
:rulebooks="store.rulebooks"
:selected-id="selectedRulebookId"
:sweep-active="sweepActive"
@select="selectRulebook"
@select-sweep="selectSweep"
/>
<RuleSweepPane v-if="sweepActive" class="sweep-span" @open-rule="openRule" />
<RulebookDetailPane
v-if="selectedRulebookId !== null"
v-else-if="selectedRulebookId !== null"
:rulebook-id="selectedRulebookId"
:topics="store.topicsByRulebook[selectedRulebookId] || []"
:selected-topic-id="selectedTopicId"
@@ -83,13 +99,13 @@ watch(() => route.query, syncFromRoute);
<p>Select a rulebook to view its topics.</p>
</div>
<RuleListPane
v-if="selectedTopicId !== null"
v-if="!sweepActive && selectedTopicId !== null"
:topic-id="selectedTopicId"
:rules="store.rulesByTopic[selectedTopicId] || []"
@open-rule="openRule"
@create-rule="startCreatingRule"
/>
<div v-else class="pane empty">
<div v-else-if="!sweepActive" class="pane empty">
<p>Select a topic to view its rules.</p>
</div>
<RuleEditorSlideOver
@@ -109,6 +125,9 @@ watch(() => route.query, syncFromRoute);
gap: 1px;
background: var(--fs-border-color);
}
/* The sweep is cross-cutting, so it takes the width the rulebook + topic
panes would have used rather than being squeezed into one column. */
.sweep-span { grid-column: 2 / -1; }
.pane.empty {
background: var(--fs-surface-hover);
padding: 1rem;
File diff suppressed because it is too large Load Diff
+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;
}
+3 -42
View File
@@ -578,6 +578,7 @@ useEditorGuards(dirty, save);
<select v-model="kind" @change="markDirty" class="sb-select">
<option value="work">Work</option>
<option value="issue">Issue</option>
<option value="spike">Spike</option>
<!-- 'plan' is retired (plans are milestones via start_planning);
offered only so legacy plan-tasks display their kind. -->
<option v-if="kind === 'plan'" value="plan">Plan (legacy)</option>
@@ -803,7 +804,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 +825,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 +832,6 @@ useEditorGuards(dirty, save);
flex-shrink: 0;
}
.body-editor-wrap {
min-height: 200px;
}
:deep(.preview-pane) {
flex-shrink: 0;
}
@@ -949,18 +937,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 +949,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>
+8 -4
View File
@@ -1,13 +1,17 @@
{
"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",
"author": { "name": "Bryan Van Deusen" },
"version": "0.1.47",
"author": {
"name": "Bryan Van Deusen"
},
"mcpServers": {
"scribe": {
"type": "http",
"url": "${user_config.api_endpoint}/mcp",
"headers": { "Authorization": "Bearer ${user_config.api_token}" }
"headers": {
"Authorization": "Bearer ${user_config.api_token}"
}
}
},
"userConfig": {
@@ -19,7 +23,7 @@
"api_token": {
"type": "string",
"title": "Scribe API key",
"description": "An fmcp_ API key from Settings API Keys (read scope is enough for the session-start hook; write scope to use the tools)",
"description": "An fmcp_ API key from Settings \u2192 API Keys (read scope is enough for the session-start hook; write scope to use the tools)",
"sensitive": true
}
}
+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
}
+60 -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,29 @@ 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.
#
# A FOURTH channel (milestone 307): standing RULES the write resembles. Its own
# file for the same reason as the others — a rule named once should not be
# re-offered on every subsequent write in the session.
idfile=""
syncfile=""
derivefile=""
rulefile=""
exclude_q=""
sync_exclude_q=""
derive_exclude_q=""
rule_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"
rulefile="$state_dir/${safe_sid}.rules.ids"
if [ -f "$idfile" ]; then
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
@@ -274,13 +200,30 @@ 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
if [ -f "$rulefile" ]; then
rule_seen=$(tr '\n' ',' < "$rulefile" 2>/dev/null | sed 's/,$//')
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_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}${rule_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 +238,12 @@ if [ -n "$body" ]; then
if [ -n "$syncfile" ]; then
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
fi
if [ -n "$rulefile" ]; then
printf '%s' "$body" | jq -r '(.rule_ids // [])[]?' 2>/dev/null >> "$rulefile" || true
fi
if [ -n "$derivefile" ]; then
printf '%s' "$body" | jq -r '(.derive_keys // [])[]?' 2>/dev/null >> "$derivefile" || true
fi
fi
fi
@@ -304,9 +253,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 +271,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]}")
+9 -1
View File
@@ -30,6 +30,7 @@ from scribe.routes.design_systems import design_systems_bp
from scribe.routes.trash import trash_bp
from scribe.routes.dashboard import dashboard_bp
from scribe.routes.systems import systems_bp
from scribe.routes.canonical_systems import canonical_systems_bp
from scribe.routes.snippets import snippets_bp
from scribe.routes.webhooks import webhooks_bp
from scribe.mcp import mount_mcp
@@ -95,6 +96,7 @@ def create_app() -> Quart:
app.register_blueprint(trash_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(systems_bp)
app.register_blueprint(canonical_systems_bp)
app.register_blueprint(snippets_bp)
app.register_blueprint(webhooks_bp)
@@ -159,7 +161,7 @@ def create_app() -> Quart:
import asyncio
from scribe.services.auth import start_auth_token_retention_loop
from scribe.services.embeddings import backfill_note_embeddings
from scribe.services.embeddings import backfill_note_embeddings, backfill_rule_embeddings
from scribe.services.logging import start_log_retention_loop
from scribe.services.notifications import start_notification_loop
@@ -174,6 +176,12 @@ def create_app() -> Quart:
await backfill_note_embeddings()
except Exception:
logger.warning("Embedding backfill failed", exc_info=True)
# Rules got vectors in milestone 307; every rule written before it
# has none, so this is the pass that makes them findable at all.
try:
await backfill_rule_embeddings()
except Exception:
logger.warning("Rule embedding backfill failed", exc_info=True)
# Snippets written before migration 0070 have no `notes.data` mirror,
# and the location reverse lookup queries that column — an unfilled
# row would read as "no snippet here" rather than as a gap. Separate
+13 -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).
@@ -92,6 +91,9 @@ _READ_ONLY_TOOLS = frozenset({
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
"list_always_on_rules", "search",
"get_system", "list_systems", "list_system_records",
# The global area catalog and its mapping REPORT — propose writes nothing;
# map_system_to_canonical is the separate, explicitly-called write.
"list_canonical_systems", "propose_canonical_mappings",
# Reports on the corpus. Reads only — the merge or supersession each
# suggests is a separate, explicitly-called write.
"find_duplicate_snippets", "find_duplicate_records",
@@ -113,6 +115,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)
+373 -18
View File
@@ -1,7 +1,12 @@
"""MCP tools for the Scribe Rulebook system.
Sixteen tools: rulebook/topic/rule CRUD + subscription management. Thin
wrappers over services/rulebooks.py — ownership is enforced in the service.
Rulebook / topic / rule CRUD, subscription management, and the rule-to-rule
edges. Thin wrappers over services/rulebooks.py — ownership is enforced in the
service, and the record shape comes from rule_brief / rule_detail there rather
than being rebuilt here.
(The header used to say "Sixteen tools" and had been wrong for two milestones;
the count lives in the registration test, which fails when it drifts.)
Destructive ops (delete_*) require confirmed=True; otherwise return a
preview-style warning. Mirrors the pattern in delete_event and the design
@@ -195,8 +200,12 @@ async def delete_topic(topic_id: int, confirmed: bool = False) -> dict:
def _rule_summary(r) -> dict:
"""The list-row shape for a rule: what an agent needs to APPLY it. The
full record (why, how_to_apply, timestamps) is get_rule's job."""
return {"id": r.id, "title": r.title, "statement": r.statement, "topic_id": r.topic_id}
full record (why, how_to_apply, timestamps) is get_rule's job.
One line, because the shape itself lives in the service — this was one of
three hand-written copies that had already drifted apart (note 3026).
"""
return rulebooks_svc.rule_brief(r)
async def list_rules(
@@ -222,31 +231,60 @@ 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.
Returns the ALWAYS-ON tier only (milestone 307). A `conditional` rule is
still binding when it applies; it just is not resident — it reaches a
session through enter_project (when the project works in an area the rule
is tagged to) or through search(content_type="rule"). Nothing here is a
behaviour change until rules are actually re-tiered: `tier` defaults to
always_on, so an existing rulebook returns exactly what it always did.
Pair with get_project(id).applicable_rules when working on a specific
project to also load that project's subscription-derived rules.
A rule carrying `last_verified` asserts a FACT about something outside the
operator's control — a runner's shell, a tool's existence, a setting
somewhere. It is still binding; the field says how long ago anyone
confirmed it, and "never" means nobody has. Follow the rule, and if you
are already standing where the check could be made, make it: get_rule
gives you its `verify_with`. Most rules have no such field, which means
they are decisions and there is nothing to check.
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)}
async def get_rule(rule_id: int) -> dict:
"""Fetch a rule by id — full statement + why + how_to_apply."""
"""Fetch a rule by id — full statement + why + how_to_apply.
Also carries what a listing leaves out: the global `systems` this rule is
about, and its `relations`. Read the relations before acting on the rule —
a rule with a `co_surfaces` edge is half of a shape, and an `overrides`
edge means one of the pair is not in force here.
"""
uid = current_user_id()
rule = await rulebooks_svc.get_rule(rule_id, uid)
if rule is None:
raise ValueError(f"rule {rule_id} not found")
return rule.to_dict()
return await rulebooks_svc.rule_detail(uid, rule)
async def create_rule(
topic_id: int, title: str, statement: str,
topic_id: int, title: str, statement: str, when_to_apply: str = "",
why: str = "", how_to_apply: str = "", order_index: int = 0,
tier: str = "always_on", system_ids: list[int] | None = None,
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
force: bool = False,
) -> dict:
"""Create a new rule in a rulebook (a SHARED rule — keep it general).
@@ -259,6 +297,13 @@ async def create_rule(
rulebook+topic ceremony). If it's a standard a CATEGORY of projects shares,
put it in a themed subscribed rulebook, not the always-on one.
Write it general WITHOUT hedging for the exceptions. A project that needs
to strengthen, narrow or replace this rule writes its own and links it
with relate_rules(kind="overrides"), and one that adds local specifics
uses "elaborates" — so the general form does not have to anticipate every
project it will ever reach. A rulebook rule padded with "unless…" clauses
for two projects is two project rules that were never written.
Before writing a rule at all, check whether another entity already models
the thing. A rule is prose an agent must remember and apply; the others
are structure a tool can resolve, render and check. Visual standards are a
@@ -267,12 +312,64 @@ async def create_rule(
Reusable code is a SNIPPET. Reach for a rule only when the thing genuinely
is a standing instruction about how to work and nothing else can hold it.
ONE RULE = ONE THING YOU COULD VIOLATE. If a clause can be broken on its
own, and fixing that breakage doesn't require the neighbouring clauses, it
is a separate rule. Rules that FAIL TOGETHER get linked with relate_rules
(kind="co_surfaces"), never merged into one row: a merged rule cannot be
cited, surfaced or suppressed a clause at a time, and it grows without
limit because adding to it is always cheaper than adding a rule.
Args:
topic_id: The topic to attach the rule to.
title: A short imperative title (e.g. "dev is home").
statement: The actionable instruction (required). 1-2 sentences.
when_to_apply: WHEN this rule fires — the trigger, not the
instruction. State the moment or the material: "before any git
push", "when adding a value to a CHECK-gated column", "when a
release is being cut". Write it even though the parameter is
optional: it decides the tier below, it is how the rule is found
when it matters, and a rule nobody can place is a rule nobody
applies.
This field is also the rule's RETRIEVAL SURFACE — it and the
statement are what a search is matched against, so it should
carry the SYMPTOM, not just the situation: the words someone
would actually type while stuck. Measured (note 3078): a rule
whose trigger named only its situation did not surface at all
for the problem it solves; adding the symptom to the same field
brought it back as the top hit. Where a rule prevents a specific
failure, put that failure's vocabulary here — the error text,
the wrong behaviour, the dead end.
tier: "always_on" (default) or "conditional".
The test: can you name the trigger WITHOUT naming a system, an
artifact type or a moment? If the honest answer is "whenever you
are working", it is always_on. If you had to name something, it is
conditional — and conditional costs nothing when it is irrelevant,
which is what lets it be as long as it needs to be.
system_ids: Ids from list_canonical_systems — the global AREAS this
rule is about. This is what lets a rule reach a project that is
working in that area, so a CI rule surfaces on a CI change.
arose_from_id: The note or task that CAUSED this rule (an incident, a
decision). Prefer this over naming the record inside `why`, which
cannot be followed and does not survive a rewording.
why: Optional rationale — the reason the rule exists.
how_to_apply: Optional operationalization — when / where it kicks in.
verify_with: How to CHECK this rule is still true. Set it only when
the rule asserts a fact about something outside your control — a
runner's shell, a bot's config, whether a tool exists. Those go
false silently, with nobody present. Give a command, a path, a
URL or a query; something runnable beats prose, because prose
has to be re-interpreted by whoever finds it.
LEAVE IT EMPTY for a rule that is a DECISION — a preference, a
standard, a way of working. A decision has no truth value: it
changes when you change it, and you know that you did. An empty
verify_with is not a gap, it is the marker for "there is nothing
to go and check," and the whole signal is worthless the moment
it is filled in out of tidiness.
expires_when: The STATE under which this rule stops being true —
"when the runner can be given a bash shell", "when the dashboard
approval setting is turned off". Deliberately not a date: a
constraint expires when the ground under it moves, not on a
schedule. Pairs with verify_with; both empty is the normal case.
order_index: Display order within the topic (default 0).
force: Bypass the near-duplicate gate. By default, a title-identical rule
already in this topic BLOCKS creation and returns its id so you update
@@ -285,15 +382,19 @@ async def create_rule(
return dedup_svc.duplicate_response(dup, "rule")
rule = await rulebooks_svc.create_rule(
topic_id=topic_id, user_id=uid,
title=title, statement=statement,
title=title, statement=statement, when_to_apply=when_to_apply,
tier=tier, arose_from_id=arose_from_id,
why=why, how_to_apply=how_to_apply, order_index=order_index,
verify_with=verify_with, expires_when=expires_when,
)
return rule.to_dict()
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
async def create_project_rule(
project_id: int, statement: str, title: str = "",
project_id: int, statement: str, title: str = "", when_to_apply: str = "",
why: str = "", how_to_apply: str = "", order_index: int = 0,
tier: str = "always_on", system_ids: list[int] | None = None,
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
force: bool = False,
) -> dict:
"""Create a rule scoped to a single project (no rulebook needed).
@@ -306,13 +407,57 @@ async def create_project_rule(
the rule is returned in get_project's applicable_rules (under
project_rules) and in list_rules(project_id=...).
ONE RULE = ONE THING YOU COULD VIOLATE — see create_rule. A rule that
STRICTENS or REPLACES an inherited one is not a fresh rule: write it, then
relate_rules(kind="overrides") to the rule it supersedes, so the pair stays
connected instead of drifting into a contradiction nobody notices. A rule
that merely adds local detail to an inherited one uses "elaborates".
Args:
project_id: The project to attach the rule to.
statement: The actionable instruction (required). 1-2 sentences.
title: Short imperative title. If empty, derived from the first ~50
characters of statement.
when_to_apply: WHEN this rule fires — the trigger, not the
instruction, and the rule's retrieval surface: name the SYMPTOM,
the words someone would type while stuck. See create_rule for the
full argument. It informs the tier below rather than deciding it,
since a project rule's tier turns on area-scope, not on whether
the trigger can be named.
tier: "always_on" (default) or "conditional". The SAME two values as
create_rule, judged against a different cost — do not import that
tool's test wholesale. There, always_on means every session in
every project, so the bar is high: the trigger must be nameless
("whenever you are working"). Here the rule is already scoped to
one project by construction, so always_on costs only that
project's sessions and the bar is correspondingly lower. A
project rule that names something specific is still ordinarily
always_on — being specific is what project rules are FOR.
Reach for conditional when the rule is about one AREA of a large
project — a CI quirk, a migration gotcha, one subsystem's
convention — so it arrives with that area instead of resident in
every session. The failure to avoid is local: forty always-on
rules on one project reproduces, inside that project, exactly the
preload bloat that made every rule compete for the same budget.
system_ids: Ids from list_canonical_systems — the global AREAS this
rule is about. Worth setting even on a project rule: it is what
lets a conditional one surface when the project is working in
that area.
arose_from_id: The note or task that CAUSED this rule. Reach for it
harder here than on a rulebook rule — a project rule usually
comes from one traceable incident in this repo, where a family
rule is more often a standing preference with no single origin.
The link is what lets a later reader judge whether the incident
still describes the project.
why: Optional rationale — the reason the rule exists.
how_to_apply: Optional operationalization — when / where it kicks in.
verify_with: How to check this rule is still true — see create_rule.
Set it when the rule asserts a fact about someone else's software;
leave it empty when the rule is a decision. Project rules are the
likelier home for a real check: they name this project's files,
paths and quirks, which is exactly the kind of claim that rots.
expires_when: The state under which the rule stops being true — see
create_rule. A state, not a date.
order_index: Display order within the project's rule list (default 0).
force: Bypass the near-duplicate gate. By default, a title-identical rule
already on this project BLOCKS creation and returns its id so you
@@ -326,33 +471,74 @@ async def create_project_rule(
return dedup_svc.duplicate_response(dup, "rule")
rule = await rulebooks_svc.create_project_rule(
project_id=project_id, user_id=uid,
title=derived_title, statement=statement,
title=derived_title, statement=statement, when_to_apply=when_to_apply,
tier=tier, arose_from_id=arose_from_id,
why=why, how_to_apply=how_to_apply, order_index=order_index,
verify_with=verify_with, expires_when=expires_when,
)
return rule.to_dict()
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
async def update_rule(
rule_id: int, title: str = "", statement: str = "",
rule_id: int, title: str = "", statement: str = "", when_to_apply: str = "",
why: str = "", how_to_apply: str = "", order_index: int = -1,
tier: str = "", system_ids: list[int] | None = None, arose_from_id: int = 0,
verify_with: str = "", expires_when: str = "",
clear_fields: list[str] | None = None,
) -> dict:
"""Update a rule. Empty strings / order_index=-1 leave fields unchanged."""
"""Update a rule. Empty strings / order_index=-1 leave fields unchanged.
Adding `when_to_apply` and a `tier` to an existing rule is the ordinary way
a rule stops being preloaded into every session and starts arriving when it
is relevant. `system_ids` REPLACES the rule's areas (pass [] to clear).
TO EMPTY A FIELD, NAME IT: clear_fields=["verify_with"]. Passing "" cannot
do it — "" means "leave this alone" here, which is what lets you update
two fields without wiping the other six. Clearable: why, how_to_apply,
when_to_apply, verify_with, expires_when, arose_from_id. Clearing and
setting the same field in one call clears it first, so the new value wins.
Editing `verify_with` DROPS the rule's verification stamp. The stamp
certifies a check, not a rule; once the check is reworded the old stamp
vouches for something that no longer exists, so the rule re-enters the
staleness sweep as never-verified.
Args:
verify_with: How to check the rule is still true — set it when the
rule asserts a fact about someone else's software, leave it empty
when the rule is a decision. See create_rule.
expires_when: The state under which the rule stops being true. A
state, not a date. See create_rule.
clear_fields: Names of fields to empty, as above.
"""
uid = current_user_id()
fields: dict = {}
if title:
fields["title"] = title
if statement:
fields["statement"] = statement
if when_to_apply:
fields["when_to_apply"] = when_to_apply
if tier:
fields["tier"] = tier
if arose_from_id:
fields["arose_from_id"] = arose_from_id
if why:
fields["why"] = why
if how_to_apply:
fields["how_to_apply"] = how_to_apply
if verify_with:
fields["verify_with"] = verify_with
if expires_when:
fields["expires_when"] = expires_when
if order_index >= 0:
fields["order_index"] = order_index
rule = await rulebooks_svc.update_rule(rule_id, uid, **fields)
rule = await rulebooks_svc.update_rule(
rule_id, uid, clear=clear_fields or (), **fields,
)
if rule is None:
raise ValueError(f"rule {rule_id} not found")
return rule.to_dict()
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
@@ -407,6 +593,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:
@@ -461,14 +676,154 @@ async def unsuppress_topic_for_project(
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
async def relate_rules(
from_rule_id: int, to_rule_id: int, kind: str, note: str = "",
) -> dict:
"""Draw a typed edge between two rules. Both must be yours.
Reach for this INSTEAD of merging or duplicating:
- kind="co_surfaces" — these two fail together, so they must arrive
together. Use it when you are tempted to fold one rule into another
because "either could surface without the other": that instinct is
right and merging is the wrong fix, because a merged rule cannot be
cited, suppressed or surfaced a clause at a time. Symmetric — draw it
once, it reads from both ends.
- kind="overrides" — this rule supersedes that one for its scope. Use it
when a project rule is stricter than, or replaces, an inherited one,
instead of writing a near-copy that will drift from its parent.
- kind="elaborates" — this rule adds local specifics to that one, and
should arrive with it rather than instead of it.
Idempotent: re-drawing an existing edge returns it.
Args:
note: WHY the edge holds. Worth writing for the same reason a rule
carries `why` — a later reader deciding whether it still applies
needs the reasoning, not just the fact.
"""
uid = current_user_id()
relation = await rulebooks_svc.add_rule_relation(
uid, from_rule_id, to_rule_id, kind, note,
)
if relation is None:
raise ValueError(
f"rule {from_rule_id} or {to_rule_id} not found (both must be yours)"
)
return relation.to_dict()
async def unrelate_rules(relation_id: int) -> dict:
"""Remove one edge between rules (from relate_rules / get_rule.relations)."""
uid = current_user_id()
if not await rulebooks_svc.remove_rule_relation(uid, relation_id):
raise ValueError(f"relation {relation_id} not found")
return {"deleted": relation_id}
# ── The staleness sweep (milestone 312) ────────────────────────────────
async def rules_due_for_verification(
older_than_days: int = 0, tier: str = "", never_only: bool = False,
) -> dict:
"""Which standing rules assert a FACT that nobody has confirmed lately.
A rulebook holds two kinds of thing. Most rules are DECISIONS — how the
operator wants to work. They have no truth value and cannot rot. A few
assert a fact about someone else's software: what a CI runner does, which
tools exist, what a setting is currently set to. Those go false silently,
with nobody present, and they keep being handed to every session as
binding instructions long after they stopped being true.
This lists the second kind, oldest verification first, never-checked at
the top. Each row carries the rule's `verify_with` in full — you are
about to go and run it — plus `expires_when`, and `days_since_verified`.
Reach for it when you are curating the rulebook, when a rule's advice
just contradicted what you observed, or periodically. Then, for each row:
run the check, and call mark_rule_verified with what you found.
Rules with no `verify_with` never appear here. That is correct: they are
decisions, and there is nothing to go and check. Do not "fix" their
absence by giving them checks — the list is only worth reading while
everything on it genuinely can go false.
Args:
older_than_days: only rules last verified longer ago than this.
Never-checked rules always qualify. 0 = no age filter.
tier: "always_on" or "conditional" to narrow. An always-on constraint
that has gone false is the expensive kind — it is preloaded into
every session, so a wrong one is wrong everywhere at once.
never_only: only rules nobody has ever verified.
NOT filterable by project, deliberately: a project reaches rules through
project scope, subscriptions, always-on rulebooks and exclusions, and a
filter that missed one of those paths would UNDER-report — which is the
exact failure this whole surface exists to prevent. Read the whole list.
"""
uid = current_user_id()
rules = await rulebooks_svc.rules_due_for_verification(
uid, older_than_days=older_than_days, tier=tier, never_only=never_only,
)
return {
"rules": [rulebooks_svc.verification_row(r) for r in rules],
"total": len(rules),
}
async def mark_rule_verified(rule_id: int, still_true: bool = True) -> dict:
"""Record that you ran a rule's check — and what it said.
Call this AFTER actually running the rule's `verify_with`, never on the
strength of the rule sounding plausible. A stamp nobody earned is worse
than no stamp: it moves the rule to the bottom of the sweep and buys it
another long silence.
`still_true=False` writes NOTHING. A rule whose check failed is not in a
special state to be recorded — it is WRONG, and the only honest next
moves are to correct it, retire it, or find out why. So it stays at the
top of the sweep until someone deals with it, and the response tells you
what the rule said would end it.
Args:
rule_id: the rule whose check you ran.
still_true: True if the check passed. False if the fact it asserts is
no longer true — say so, that is the outcome worth having.
"""
uid = current_user_id()
rule = await rulebooks_svc.mark_rule_verified(rule_id, uid, still_true)
if rule is None:
raise ValueError(
f"rule {rule_id} not found, or carries no verify_with "
f"(nothing to verify is not the same as verified)"
)
data = await rulebooks_svc.rule_detail(uid, rule)
if still_true:
data["verified"] = True
return data
data["verified"] = False
data["next"] = (
"This rule is no longer true and is still binding on every session "
"that loads it. Correct it with update_rule, retire it with "
"delete_rule, or open a task to work out what replaced it. Its "
"verified_at is deliberately untouched, so it stays at the top of "
"rules_due_for_verification until one of those happens."
)
return data
def register(mcp) -> None:
for fn in (
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
list_topics, create_topic, update_topic, delete_topic,
list_rules, list_always_on_rules, get_rule,
create_rule, create_project_rule, update_rule, delete_rule,
relate_rules, unrelate_rules,
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,
rules_due_for_verification, mark_rule_verified,
):
mcp.tool(name=fn.__name__)(fn)
+104 -3
View File
@@ -11,8 +11,54 @@ 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.embeddings import (
DEFAULT_SIMILARITY_THRESHOLD, semantic_search_notes, semantic_search_rules,
)
from scribe.services import rulebooks as rulebooks_svc
from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summary
async def _search_rules(uid: int, q: str, limit: int) -> dict:
"""Rules by meaning — a separate result shape because a rule IS different.
A rule hit carries `why` and `how_to_apply`: they are the operational half
of a rule and the session-start payload never includes them, so a caller
who went looking should get the whole thing rather than a summary they then
have to re-fetch. It also carries the rule's check (`verify_with`,
`expires_when`, `last_verified`) when it has one — a search hit is exactly
the moment someone is about to act on a rule, and "this asserts a fact
nobody has confirmed" is part of what the rule says.
Rules are not project-scoped the way notes are (a family rule belongs to no
project), so `project_id` and `system_id` do not apply here.
"""
raw = await semantic_search_rules(uid, q, limit=limit)
return {
"results": [
{
"id": rule.id,
"title": rule.title,
"statement": rule.statement,
"when_to_apply": rule.when_to_apply or "",
"tier": rule.tier,
"why": rule.why or "",
"how_to_apply": rule.how_to_apply or "",
"verify_with": rule.verify_with or "",
"expires_when": rule.expires_when or "",
# Only on a rule that carries a check; its absence means the
# rule is a decision, not that nobody has looked.
**(
{"last_verified": rulebooks_svc.last_verified_label(rule)}
if rule.verify_with else {}
),
"topic_id": rule.topic_id,
"project_id": rule.project_id,
"similarity": float(score),
}
for score, rule in raw
],
"total": len(raw),
}
async def search(
@@ -33,7 +79,13 @@ async def search(
Args:
q: search query string.
content_type: 'all' (default), 'note' (notes only), or 'task' (tasks only).
content_type: 'all' (default), 'note' (notes only), 'task' (tasks
only), or 'rule' (RULES only — the operator's standing
instructions, searchable by meaning since milestone 307).
Reach for 'rule' when you want to know whether a standing
instruction covers something: "is there a rule about release
tagging?". A hit carries the rule's `why` and `how_to_apply`,
which the session-start payload does not.
limit: maximum number of results (1-50).
project_id: Scope results to one project. PASS THE ACTIVE PROJECT'S ID
whenever a project is in scope (the one you entered with
@@ -56,6 +108,8 @@ async def search(
"""
uid = current_user_id()
limit = max(1, min(limit, 50))
if content_type == "rule":
return await _search_rules(uid, q, limit)
is_task = {"note": False, "task": True}.get(content_type) # None => any
t0 = time.perf_counter()
raw = await semantic_search_notes(
@@ -95,5 +149,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
+126 -31
View File
@@ -13,6 +13,7 @@ Sentinels (match the milestone/task tool conventions):
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import canonical_systems as canonical_systems_svc
from scribe.services import notes as notes_svc
from scribe.services import systems as systems_svc
@@ -30,10 +31,9 @@ _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 in the GLOBAL canonical catalog since
# milestone 307 — the inception seed mints it and this ask names it, one list
# for both, now a table so a rule can reference an area by id (note 3026).
async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
@@ -67,6 +67,17 @@ async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
titles = "; ".join(
'"' + " ".join((n.title or "").split())[:70] + '"' for n in recent
)
try:
standard = ", ".join(name for name, _charter in await systems_svc.standard_systems())
except Exception:
standard = ""
# An install whose catalog is empty still gets a usable ask — the standard
# names are an aid, not the point of the question.
standard_line = (
"Where an area fits a standard name, use it verbatim so it means the "
f"same thing in every project: {standard}. Mint freely beyond that "
"list — the duplicate gate guards sprawl. "
) if standard else "The duplicate gate guards sprawl. "
return (
f"This project has {total} records and NO Systems modelled — none of "
"them can be tagged to an area, so recurring problem-spots stay "
@@ -74,10 +85,8 @@ async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
"asking permission — creating Systems is your call, not an approval "
f"flow. From the areas the records themselves name (recent: {titles}), "
"create_system 3-6 Systems, each with a one-paragraph charter, then "
"tag this record (system_ids=[...]). Where an area fits a standard "
f"name, use it verbatim so it means the same thing in every project: "
f"{', '.join(_STANDARD_SYSTEMS)}. Mint freely beyond that list — the "
"duplicate gate guards sprawl. This ask repeats until the first "
f"tag this record (system_ids=[...]). {standard_line}"
"This ask repeats until the first "
"System exists; answering it once retires it for every future record."
)
@@ -188,36 +197,60 @@ async def create_system(
normalized name already exists in this project (archived included), the
call returns {"duplicate": true, "existing_id": ...} instead of creating —
tag records to that one, or update_system it if its charter needs work.
Also mapped against the GLOBAL area catalog, so the same word means the
same thing in every project (milestone 307). A name that IS a catalog area
up to spelling ("CI and Release" vs "CI & Release") is mapped for you and
the response says so. A name that merely RESEMBLES one comes back with
`canonical_suggestion` — an offer, not a decision: apply it with
map_system_to_canonical if it really is that area, ignore it if this is a
project-specific area. Either way the System is created; the catalog never
blocks a name.
"""
uid = current_user_id()
norm = " ".join(name.split()).lower()
if norm:
try:
existing = await systems_svc.list_systems(
uid, project_id, include_archived=True
)
except Exception:
existing = []
for s in existing:
if " ".join(s.name.split()).lower() == norm:
return {
"duplicate": True,
"existing_id": s.id,
"message": (
f"System '{s.name}' (#{s.id}) already covers this area "
"in this project. Tag records to it with system_ids, "
"or update_system it if the charter needs revising — "
"a second System with the same name would split the "
"area's records across two piles."
),
}
assessment = await systems_svc.assess_system_name(uid, project_id, name)
duplicate = assessment["duplicate"]
if duplicate:
return {
"duplicate": True,
"existing_id": duplicate["id"],
"message": (
f"System '{duplicate['name']}' (#{duplicate['id']}) already "
"covers this area in this project. Tag records to it with "
"system_ids, or update_system it if the charter needs "
"revising — a second System with the same name would split "
"the area's records across two piles."
),
}
# An exact match is mechanical, so it is applied; an overlap is a judgment
# call, so it is only offered (see services/canonical_systems).
canonical = assessment["canonical"]
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
system = await systems_svc.create_system(
uid, project_id=project_id, name=name,
description=description or None, color=color or None,
canonical_id=applied,
)
if system is None:
raise ValueError(f"cannot create system in project {project_id} (no write access)")
return system.to_dict()
out = system.to_dict()
if applied:
out["canonical_note"] = (
f"Mapped to the global area '{canonical['name']}' — the same "
"spelling-insensitive name. Your System keeps the name you gave it."
)
elif canonical:
out["canonical_suggestion"] = {
**canonical,
"message": (
f"The global catalog has '{canonical['name']}', which may be "
f"this same area. If it is, map_system_to_canonical("
f"{system.id}, {canonical['id']}) so records and rules about "
"this area line up across projects. If this area is specific "
"to this project, ignore it — unmapped is a valid state."
),
}
return out
async def list_systems(project_id: int, include_archived: bool = False) -> dict:
@@ -303,7 +336,8 @@ async def list_system_records(
slice, search(system_id=...) filters semantic search to this association.
Args:
kind: filter by task_kind — 'issue', 'work', or 'plan'. Omit for all.
kind: filter by task_kind — 'issue', 'work', 'spike' (or the retired
'plan'). Omit for all.
open_only: limit to tasks not done/cancelled (e.g. open issues only).
"""
uid = current_user_id()
@@ -322,6 +356,64 @@ async def delete_system(system_id: int) -> dict:
return {"message": f"System {system_id} deleted."}
async def list_canonical_systems() -> dict:
"""The GLOBAL vocabulary of area names, shared by every project.
These are the standard names to prefer when creating a System, so the same
word means the same thing in every project on the instance — and, from
milestone 307, the ids a cross-project record can point at. A project's own
System keeps whatever name the project calls the area; mapping it here is
an association, never a rename.
Reach for it before create_system when the area is an ordinary one (CI,
auth, storage, the API, the UI), and pass the matching `canonical_id`.
"""
entries = await canonical_systems_svc.list_canonical_systems()
return {"canonical_systems": [e.to_dict() for e in entries]}
async def propose_canonical_mappings(project_id: int) -> dict:
"""Suggest a global area for each of this project's UNMAPPED Systems.
Returns PROPOSALS ONLY — nothing is written. Confirm the ones that are
right with map_system_to_canonical(system_id, canonical_id); ignore the
rest. Each carries a `basis`:
- `exact` — the names reduce to the same match key ("CI and Release" vs
"CI & Release"). Safe to confirm without much thought.
- `overlap` — they share a meaningful word ("CI & runners" vs "CI &
Release"). A judgment call: confirm only if they really are the same
area, since a wrong mapping surfaces cross-project records in the wrong
place.
A System with no proposal is not a problem — unmapped is a valid resting
state, and a genuinely project-specific area should stay that way.
"""
uid = current_user_id()
return {"proposals": await canonical_systems_svc.propose_mappings(uid, project_id)}
async def map_system_to_canonical(system_id: int, canonical_id: int = 0) -> dict:
"""Map one of a project's Systems onto a global area (or clear it).
Sets `canonical_id` and NOTHING else — the System's name, charter and every
record tagged to it are untouched. Pass canonical_id=0 to unmap.
Args:
canonical_id: id from list_canonical_systems; 0 clears the mapping.
"""
uid = current_user_id()
system = await canonical_systems_svc.set_system_canonical(
uid, system_id, canonical_id or None,
)
if system is None:
raise ValueError(
f"system {system_id} not found, no write access, "
f"or canonical_id {canonical_id} is not a live catalog entry"
)
return system.to_dict()
def register(mcp) -> None:
for fn in (
create_system,
@@ -330,5 +422,8 @@ def register(mcp) -> None:
update_system,
list_system_records,
delete_system,
list_canonical_systems,
propose_canonical_mappings,
map_system_to_canonical,
):
mcp.tool(name=fn.__name__)(fn)
+33 -8
View File
@@ -23,6 +23,11 @@ from scribe.mcp.tools import systems as systems_tools
from scribe.services import access as access_svc
from scribe.services import dedup as dedup_svc
from scribe.services import notes as notes_svc
# Imported by NAME, not reached through notes_svc: minted_kind is pure
# validation, not a service call, and a test that stubs the service module to
# avoid the database would otherwise stub the validation too — turning a
# guard into a MagicMock that approves anything.
from scribe.services.notes import minted_kind
from scribe.services import planning as planning_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
@@ -46,7 +51,8 @@ async def list_tasks(
whenever a project is in scope so you list that project's tasks, not
every project's. 0 = no filter (all projects — use only for a
deliberate cross-project view).
kind: Filter by task kind — 'work', 'plan', or 'issue'. Omit (empty) for all kinds.
kind: Filter by task kind — 'work', 'issue', 'spike' (or the retired
'plan'). Omit (empty) for all kinds.
Results are ordered by last-updated descending.
"""
@@ -138,14 +144,24 @@ async def create_task(
milestone_id: Place within a project milestone (0 = no milestone).
parent_id: Make this a sub-task of another task (0 = top-level).
tags: List of plain-string tags without # prefix.
kind: 'work' (default) or 'issue'. An issue is corrective work — a
problem you fixed or are fixing; record symptom → root cause → fix
in the body. (Plans are milestones now — call start_planning to begin
a plan; 'plan' is not a valid kind here.)
kind: 'work' (default), 'issue', or 'spike'.
An ISSUE is corrective work — a problem you fixed or are fixing;
record symptom → root cause → fix in the body.
A SPIKE is time-boxed and its output is KNOWLEDGE rather than a
change: "find out whether the runner can be given a bash shell",
"work out why the index is not used". It succeeds by producing an
answer, so nothing ships at the end of it — which is why filing
one as `work` makes a finished investigation look like an
abandoned change. Reach for it when the honest deliverable is a
finding, and say in the body what would close the box: a time, or
the question being answered well enough to act on.
(Plans are milestones now — call start_planning to begin a plan;
'plan' is not a valid kind here.)
system_ids: Ids of the project's Systems (reusable subsystem/area
objects; see list_systems / create_system) to associate this task with.
arose_from_id: For an issue, the id of the task/feature it arose from
(provenance). 0 = none.
arose_from_id: For an issue, the id of the task/feature it arose from;
for a spike, the record that raised the question — including a
standing rule whose check just failed. 0 = none.
force: Bypass the near-duplicate gate. By default, if a title- or
meaning-similar task already exists in the same project, creation is
BLOCKED and the existing task's id is returned so you update it
@@ -183,7 +199,7 @@ async def create_task(
milestone_id=milestone_id or None,
parent_id=parent_id or None,
tags=tags,
task_kind=kind,
task_kind=minted_kind(kind),
arose_from_id=arose_from_id or None,
)
if system_ids:
@@ -203,6 +219,7 @@ async def update_task(
milestone_id: int = 0,
system_ids: list[int] | None = None,
arose_from_id: int = 0,
kind: str = "",
) -> dict:
"""Update an existing Scribe task. Only explicitly provided fields are changed.
@@ -222,6 +239,12 @@ async def update_task(
(set-semantics). None = leave unchanged; [] = clear all.
arose_from_id: Provenance (issue → originating task). 0 = leave unchanged,
-1 = clear, positive = set.
kind: Re-file this task as 'work', 'issue' or 'spike'. Omit (empty) to
leave unchanged. Correcting a kind is ordinary — what a task turns
out to BE is often clear only once it is under way, and a piece of
work that becomes an investigation should say so. 'plan' is
refused: plans are milestones (start_planning), and the value
survives only so historical plan-tasks stay writable.
"""
uid = current_user_id()
fields: dict = {}
@@ -247,6 +270,8 @@ async def update_task(
fields["arose_from_id"] = None
elif arose_from_id:
fields["arose_from_id"] = arose_from_id
if kind:
fields["task_kind"] = minted_kind(kind)
note = await notes_svc.update_note(uid, task_id, **fields)
if note is None:
raise ValueError(f"task {task_id} not found")
+6 -3
View File
@@ -25,7 +25,7 @@ from scribe.models.user import User # noqa: E402, F401
from scribe.models.app_log import AppLog # noqa: E402, F401
from scribe.models.password_reset import PasswordResetToken # noqa: E402, F401
from scribe.models.invitation import InvitationToken # noqa: E402, F401
from scribe.models.embedding import NoteEmbedding # noqa: E402, F401
from scribe.models.embedding import NoteEmbedding, RuleEmbedding # noqa: E402, F401
from scribe.models.retrieval_log import RetrievalLog # noqa: E402, F401
from scribe.models.note_usage import NoteUsageEvent # noqa: E402, F401
from scribe.models.project import Project # noqa: E402, F401
@@ -39,11 +39,14 @@ from scribe.models.share import NoteShare, ProjectShare # noqa: E402, F401
from scribe.models.notification import Notification # noqa: E402, F401
from scribe.models.api_key import ApiKey # noqa: E402, F401
from scribe.models.user_profile import UserProfile # noqa: E402, F401
# Imported before rulebook: rule_systems foreign-keys canonical_systems.
from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401
from scribe.models.rulebook import ( # noqa: E402, F401
Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions,
Rulebook, RulebookTopic, Rule, RuleRelation, project_rulebook_subscriptions,
rule_systems,
)
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
+58
View File
@@ -0,0 +1,58 @@
from sqlalchemy import Index, Integer, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class CanonicalSystem(Base, TimestampMixin, SoftDeleteMixin):
"""A GLOBAL area name — the shared vocabulary every project's Systems
can point at (milestone 307, decision note 3026).
A `System` is per-project and NOT NULL on project_id, so nothing outside a
project can reference one: a rule that applies across projects has no way
to say "this is about CI" without chaining itself to one project's row.
This table is that join key. It carries no `user_id` on purpose — a shared
project must INHERIT the vocabulary rather than re-earn it, so the catalog
is global and the same word means the same thing in every install.
It is a convergence aid, never a gate: `systems.canonical_id` is nullable,
an unmapped System stays fully usable, and `record_systems` never sees this
table at all — the local name is a legitimate local label and is never
rewritten to match.
`slug` is the match key, not a display value. It folds the spelling
differences that produced four names for one area on the author's own
instance ("CI & Release" / "CI and Release" / "CI & release"): an exact slug
hit maps automatically, and anything short of that becomes a proposal for a
human to confirm. See services/canonical_systems.canonical_slug.
"""
__tablename__ = "canonical_systems"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(Text, nullable=False)
# Normalized match key — unique among LIVE rows, so a soft-deleted entry
# doesn't block recreating the same area (the partial-unique convention
# rules/topics already use).
slug: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
__table_args__ = (
Index(
"uq_canonical_systems_slug", "slug",
unique=True, postgresql_where=text("deleted_at IS NULL"),
),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"slug": self.slug,
"description": self.description,
"order_index": self.order_index,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
+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")
+47 -1
View File
@@ -1,7 +1,7 @@
from datetime import datetime, timezone
from pgvector.sqlalchemy import Vector
from sqlalchemy import DateTime, ForeignKey, Integer, Text
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
@@ -45,3 +45,49 @@ class NoteEmbedding(Base):
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)
class RuleEmbedding(Base):
"""One embedding vector per CHUNK of a rule (milestone 307, note 3026).
A SIBLING of NoteEmbedding rather than a generalisation of it, decided
deliberately:
- The embedding ROW could have been made polymorphic. The SEARCH could not.
`semantic_search_notes` is a long function of Note-specific scoping —
the visibility clause, the supersession penalty, note_type/task_kind and
system filters — and a rule shares none of it. Rules scope by rulebook
ownership and project applicability instead.
- Generalising the row while still needing two searches is the worst of
both: a polymorphic key with referential integrity to neither table, on
the path every session start runs, to share four columns.
- What is genuinely common is BEHAVIOUR, not storage — get_embedding,
chunk_document, embedding_text and CHUNKER_VERSION are already free
functions and are reused as-is. Sharing those is the DRY win; sharing
the table would have been the DRY costume.
No `user_id`: NoteEmbedding carries one and its own search deliberately
ignores it (scoping on the note instead, or shared records become
unreachable). Rather than repeat a column that exists to be ignored, a
rule's reach is resolved by joining the rule.
"""
__tablename__ = "rule_embeddings"
rule_id: Mapped[int] = mapped_column(
BigInteger,
ForeignKey("rules.id", ondelete="CASCADE"),
primary_key=True,
)
chunk_index: Mapped[int] = mapped_column(Integer, primary_key=True)
embedding: Mapped[list] = mapped_column(Vector(EMBEDDING_DIM), nullable=False)
# Exactly what this vector encodes — inspectable when a ranking surprises.
# For a rule this is the trigger-first document, NOT the rule's `why`:
# `why` is dated incident narrative and would drag every rule toward one
# centroid (measured in note 2485).
chunk_text: Mapped[str] = mapped_column(Text, nullable=False)
chunker_version: Mapped[int] = mapped_column(Integer, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)
+26 -2
View File
@@ -23,6 +23,24 @@ class TaskPriority(str, enum.Enum):
high = "high"
class TaskKind(str, enum.Enum):
"""What KIND of work a task is. Mirrors CHECK notes_task_kind_check.
Every value the COLUMN may hold, including `plan`. That is deliberate:
plans became milestones in 0066, but historical plan-tasks still carry
the value and must stay readable and writable. Refusing to MINT a new
plan is a door policy (see the create/update task tools), not a
statement about what the column accepts — conflating the two would make
old rows unwritable, which is how a retired value turns into corrupt
data.
"""
work = "work"
issue = "issue"
spike = "spike"
plan = "plan"
class Note(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "notes"
@@ -61,10 +79,16 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
# Note type — 'note' (default) or 'process' (a stored process). Task-ness is
# tracked by `status`, not here. (person/place/list entity types removed 2026-07.)
note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note")
# Task sub-kind — 'work' (default), 'plan', or 'issue' (corrective work).
# Task sub-kind — what KIND of work this is, not how it is going:
# work (default) — ships a change
# issue — corrective; something was broken (0065)
# spike — time-boxed, and its output is KNOWLEDGE rather than a change;
# it succeeds by producing an answer, and nothing ships (0091)
# plan — retired since 0066 (plans are milestones), kept in the CHECK
# so historical plan-tasks stay writable
# Only meaningful when the note is a task (status is not None); ordinary
# notes keep the 'work' default and ignore it. Orthogonal to note_type
# (which is the note/entity axis).
# (which is the note/entity axis). CHECK notes_task_kind_check (rule 36).
task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work")
# Queryable structured fields for typed records — currently snippets, whose
# name/language/signature/locations live here so they can be INDEXED. The
+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),
}
+123 -2
View File
@@ -1,10 +1,13 @@
from datetime import datetime, timezone
from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table, Text, text
from sqlalchemy import (
BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table,
Text, UniqueConstraint, text,
)
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
from scribe.models.base import CreatedAtMixin, SoftDeleteMixin, TimestampMixin, iso
class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
@@ -90,8 +93,46 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
)
title: Mapped[str] = mapped_column(Text)
statement: Mapped[str] = mapped_column(Text)
# WHEN this rule applies — the trigger, not the instruction. Required of
# new rules at the service layer and nullable here, because rules written
# before migration 0088 have none and a migration cannot invent one.
# It carries three jobs at once (note 3026): it is the tier test made
# concrete, the readable form of the canon tag, and the half of the
# document that makes a rule findable by meaning.
when_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
# always_on = preloaded into every session, as every rule is today.
# conditional = reachable, and surfaced when its trigger fires. The
# default preserves existing behaviour exactly: nothing stops binding
# because of an upgrade. CHECK ck_rules_tier (migration 0088, rule 36).
tier: Mapped[str] = mapped_column(Text, default="always_on", server_default="always_on")
why: Mapped[str | None] = mapped_column(Text, nullable=True)
how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
# The three fields that tell a CONSTRAINT apart from a NORM (milestone
# 312). A norm is a decision — no truth value, changes only when its
# author changes it. A constraint asserts a fact about someone else's
# software, and goes false with nobody watching: every stale rule the
# 307 audit found was one, and no norm had rotted.
#
# `verify_with` is how to check the rule is still true; `expires_when` is
# the STATE that ends it, deliberately not a date — constraints expire
# when the ground moves, not on a schedule. `verified_at` NULL means
# never checked, and sorts FIRST in the sweep: unexamined outranks
# examined-long-ago.
#
# Most rules should leave all three empty. A null `verify_with` is not a
# gap — it is the marker for "this is a decision, there is nothing to go
# and check," and the signal is only worth reading while that stays true.
verify_with: Mapped[str | None] = mapped_column(Text, nullable=True)
expires_when: Mapped[str | None] = mapped_column(Text, nullable=True)
verified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# The record that caused this rule — the edge notes and tasks already
# have. Rule 46's `why` names note 2813 in prose; this is that link as a
# field, so it survives a rewording of the paragraph.
arose_from_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
order_index: Mapped[int] = mapped_column(Integer, default=0)
def to_dict(self) -> dict:
@@ -101,14 +142,81 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
"project_id": self.project_id,
"title": self.title,
"statement": self.statement,
"when_to_apply": self.when_to_apply or "",
"tier": self.tier,
"why": self.why or "",
"how_to_apply": self.how_to_apply or "",
"verify_with": self.verify_with or "",
"expires_when": self.expires_when or "",
"verified_at": iso(self.verified_at),
"arose_from_id": self.arose_from_id,
"order_index": self.order_index,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
# Which global AREA a rule is about (milestone 307). Points at the canonical
# catalog, NEVER at a project's `systems` row: a rule that spans projects
# cannot be chained to one project's vocabulary. This is the edge three
# projects were drawing by hand, as rule text copied into a System's charter.
rule_systems = Table(
"rule_systems",
Base.metadata,
Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
Column("canonical_id", Integer, ForeignKey("canonical_systems.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
class RuleRelation(Base, CreatedAtMixin):
"""A typed edge between two rules. Each kind exists because its ABSENCE
forced a workaround somewhere in the operator's rulebook (note 3026).
- ``co_surfaces`` — these fail together, so they must arrive together.
Without it, the only way to guarantee that was to merge them into one
row, which is what happened to rule 46: split into 144, folded back the
same day because "either rule could surface without the other."
Symmetric in meaning; stored once and read both ways.
- ``overrides`` — this rule supersedes that one for its scope. Only
*suppression* existed, so an override had to be written as a parallel
rule that then drifts from its parent.
- ``elaborates`` — this rule adds local specifics to that one; surfacing
the parent brings the addendum with it.
``note`` records WHY the edge was drawn, for the same reason a rule
carries `why`: a later reader deciding whether it still holds needs the
reasoning, not just the fact.
"""
__tablename__ = "rule_relations"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
from_rule_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True
)
to_rule_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True
)
# CHECK ck_rule_relations_kind (migration 0088, rule 36).
kind: Mapped[str] = mapped_column(Text)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
UniqueConstraint("from_rule_id", "to_rule_id", "kind", name="uq_rule_relations_edge"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"from_rule_id": self.from_rule_id,
"to_rule_id": self.to_rule_id,
"kind": self.kind,
"note": self.note or "",
"created_at": iso(self.created_at),
}
# Pure many-to-many — no model class, just the join table.
project_rulebook_subscriptions = Table(
"project_rulebook_subscriptions",
@@ -129,6 +237,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,
+10
View File
@@ -24,6 +24,15 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
Integer, ForeignKey("projects.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(Text, default="", server_default="")
# The GLOBAL area this local System is an instance of (milestone 307).
# Nullable and SET NULL on purpose: the catalog is a convergence aid, not a
# gate — an unmapped System is fully usable, and retiring a canonical entry
# must never take a project's System with it. The local `name` is NEVER
# rewritten to match the canonical one; this column is the join key, and
# the name stays whatever the project calls the area.
canonical_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("canonical_systems.id", ondelete="SET NULL"), nullable=True
)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
color: Mapped[str | None] = mapped_column(Text, nullable=True)
# active | archived — systems accumulate; archive rather than delete.
@@ -40,6 +49,7 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
"user_id": self.user_id,
"project_id": self.project_id,
"name": self.name,
"canonical_id": self.canonical_id,
"description": self.description,
"color": self.color,
"status": self.status,
+93
View File
@@ -0,0 +1,93 @@
"""Canonical-system routes — the GLOBAL area vocabulary, and the mapping of a
project's Systems onto it (milestone 307, decision note 3026).
Two shapes live here because they are two halves of one idea:
- `/api/canonical-systems` — the catalog itself. Readable by any signed-in
user (it is shared vocabulary, not user data); writable only by an admin,
since a global list anyone can extend stops being a shared list.
- the mapping endpoints — authorised by the PROJECT, because mapping writes a
project's own System row. The service enforces both; these are thin wrappers.
"""
import logging
from quart import Blueprint, jsonify, request
from scribe.auth import admin_required, get_current_user_id, login_required
from scribe.routes.utils import not_found
from scribe.services import canonical_systems as canonical_svc
from scribe.services.projects import get_project_for_user
logger = logging.getLogger(__name__)
canonical_systems_bp = Blueprint("canonical_systems", __name__, url_prefix="/api")
@canonical_systems_bp.route("/canonical-systems", methods=["GET"])
@login_required
async def list_canonical_systems_route():
entries = await canonical_svc.list_canonical_systems()
return jsonify({"canonical_systems": [e.to_dict() for e in entries]})
@canonical_systems_bp.route("/canonical-systems", methods=["POST"])
@admin_required
async def create_canonical_system_route():
uid = get_current_user_id()
data = await request.get_json() or {}
if not (data.get("name") or "").strip():
return jsonify({"error": "name is required"}), 400
entry = await canonical_svc.create_canonical_system(
uid, data["name"], description=data.get("description"),
)
if entry is None:
return jsonify({"error": "Permission denied"}), 403
# The slug duplicate gate answers with the entry that already covers the
# area rather than minting a second spelling of it — 409, not a silent
# second row (the whole point of the table).
if isinstance(entry, dict):
return jsonify(entry), 409
return jsonify(entry.to_dict()), 201
@canonical_systems_bp.route("/canonical-systems/<int:canonical_id>", methods=["PATCH"])
@admin_required
async def update_canonical_system_route(canonical_id: int):
uid = get_current_user_id()
data = await request.get_json() or {}
fields = {k: v for k, v in data.items() if k in ("name", "description", "order_index")}
entry = await canonical_svc.update_canonical_system(uid, canonical_id, **fields)
if entry is None:
return not_found("Canonical system")
return jsonify(entry.to_dict())
@canonical_systems_bp.route(
"/projects/<int:project_id>/canonical-proposals", methods=["GET"]
)
@login_required
async def propose_canonical_mappings_route(project_id: int):
"""Proposals only — this endpoint writes nothing. The PUT below applies one."""
uid = get_current_user_id()
if await get_project_for_user(uid, project_id) is None:
return not_found("Project")
return jsonify({"proposals": await canonical_svc.propose_mappings(uid, project_id)})
@canonical_systems_bp.route("/systems/<int:system_id>/canonical", methods=["PUT"])
@login_required
async def map_system_to_canonical_route(system_id: int):
"""Map or unmap one System. Body: {"canonical_id": <id>|null}.
Sets that column and nothing else — no rename, no change to which records
are tagged to the System.
"""
uid = get_current_user_id()
data = await request.get_json() or {}
canonical_id = data.get("canonical_id")
if canonical_id is not None and not isinstance(canonical_id, int):
return jsonify({"error": "canonical_id must be an integer or null"}), 400
system = await canonical_svc.set_system_canonical(uid, system_id, canonical_id)
if system is None:
return not_found("System or canonical system")
return jsonify(system.to_dict())
+15
View File
@@ -129,6 +129,15 @@ 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_rule_ids (opt) — comma-separated RULE ids already surfaced
this session. Its own channel like the three
above, and for the same reason: a rule named
twenty turns ago should not be re-offered on
every subsequent write.
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 +153,10 @@ 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()
]
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
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 +166,8 @@ 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,
exclude_rule_ids=exclude_rule_ids,
)
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"])
+140 -7
View File
@@ -162,33 +162,80 @@ async def create_rule(topic_id: int):
why=data.get("why", ""),
how_to_apply=data.get("how_to_apply", ""),
order_index=data.get("order_index", 0),
when_to_apply=data.get("when_to_apply", ""),
tier=data.get("tier", "always_on"),
arose_from_id=data.get("arose_from_id", 0) or 0,
verify_with=data.get("verify_with", ""),
expires_when=data.get("expires_when", ""),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify(rule.to_dict()), 201
return jsonify(await rulebooks_svc.rule_detail(
get_current_user_id(), rule, data.get("system_ids"),
)), 201
@rulebooks_bp.get("/rules/<int:rule_id>")
@login_required
async def get_rule(rule_id: int):
rule = await rulebooks_svc.get_rule(rule_id, get_current_user_id())
uid = get_current_user_id()
rule = await rulebooks_svc.get_rule(rule_id, uid)
if rule is None:
return jsonify({"error": "rule not found"}), 404
return jsonify(rule.to_dict())
return jsonify(await rulebooks_svc.rule_detail(uid, rule))
@rulebooks_bp.patch("/rules/<int:rule_id>")
@login_required
async def update_rule(rule_id: int):
data = await request.get_json() or {}
uid = get_current_user_id()
fields = {
k: v for k, v in data.items()
if k in ("title", "statement", "why", "how_to_apply", "order_index")
if k in ("title", "statement", "why", "how_to_apply", "order_index",
"when_to_apply", "tier", "arose_from_id",
"verify_with", "expires_when")
}
rule = await rulebooks_svc.update_rule(rule_id, get_current_user_id(), **fields)
# No clear_fields here: a form sends "" for an emptied input, and the
# service normalises "" to NULL for every nullable text column. The MCP
# door needs the explicit list only because "" already means "unchanged"
# there — two idioms, one outcome.
rule = await rulebooks_svc.update_rule(rule_id, uid, **fields)
if rule is None:
return jsonify({"error": "rule not found"}), 404
return jsonify(rule.to_dict())
return jsonify(await rulebooks_svc.rule_detail(uid, rule, data.get("system_ids")))
@rulebooks_bp.post("/rules/<int:rule_id>/relations")
@login_required
async def relate_rules(rule_id: int):
"""Draw a typed edge FROM this rule to another.
Body: {"to_rule_id": N, "kind": "co_surfaces"|"overrides"|"elaborates",
"note": "..."}. Idempotent — re-drawing an edge returns the existing one.
"""
data = await request.get_json() or {}
to_rule_id = data.get("to_rule_id")
if not isinstance(to_rule_id, int):
return jsonify({"error": "to_rule_id is required"}), 400
try:
relation = await rulebooks_svc.add_rule_relation(
get_current_user_id(), rule_id, to_rule_id,
data.get("kind", ""), data.get("note", ""),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if relation is None:
return jsonify({"error": "rule not found"}), 404
return jsonify(relation.to_dict()), 201
@rulebooks_bp.delete("/rule-relations/<int:relation_id>")
@login_required
async def unrelate_rules(relation_id: int):
if not await rulebooks_svc.remove_rule_relation(get_current_user_id(), relation_id):
return jsonify({"error": "relation not found"}), 404
return "", 204
@rulebooks_bp.delete("/rules/<int:rule_id>")
@@ -288,6 +335,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):
@@ -306,7 +379,67 @@ async def create_project_rule(project_id: int):
why=data.get("why", ""),
how_to_apply=data.get("how_to_apply", ""),
order_index=data.get("order_index", 0),
when_to_apply=data.get("when_to_apply", ""),
tier=data.get("tier", "always_on"),
arose_from_id=data.get("arose_from_id", 0) or 0,
verify_with=data.get("verify_with", ""),
expires_when=data.get("expires_when", ""),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify(rule.to_dict()), 201
return jsonify(await rulebooks_svc.rule_detail(
get_current_user_id(), rule, data.get("system_ids"),
)), 201
# ── The staleness sweep (milestone 312) ────────────────────────────────
@rulebooks_bp.get("/rules-due-for-verification")
@login_required
async def rules_due_for_verification():
"""Rules that carry a check, oldest verification first, never-checked top.
Query params: older_than_days, tier, never_only. A rule with no
`verify_with` never appears — it is a decision, not a fact.
"""
uid = get_current_user_id()
args = request.args
try:
older = int(args.get("older_than_days", 0) or 0)
except ValueError:
return jsonify({"error": "older_than_days must be an integer"}), 400
try:
rules = await rulebooks_svc.rules_due_for_verification(
uid,
older_than_days=older,
tier=args.get("tier", ""),
never_only=args.get("never_only", "").lower() in ("1", "true", "yes"),
)
except ValueError as exc:
# An unrecognised tier is a 400, not a silently narrowed result set:
# a filter that quietly answers a different question is the failure
# this whole surface exists to catch.
return jsonify({"error": str(exc)}), 400
return jsonify({
"rules": [rulebooks_svc.verification_row(r) for r in rules],
"total": len(rules),
})
@rulebooks_bp.post("/rules/<int:rule_id>/verify")
@login_required
async def mark_rule_verified(rule_id: int):
"""Record that the rule's check was run. Body: {"still_true": bool}.
`still_true: false` writes nothing — a rule whose check failed is wrong,
not in a recordable state — so it stays at the top of the sweep.
"""
data = await request.get_json() or {}
uid = get_current_user_id()
still_true = data.get("still_true", True)
rule = await rulebooks_svc.mark_rule_verified(rule_id, uid, bool(still_true))
if rule is None:
return jsonify({"error": "rule not found, or carries no verify_with"}), 404
payload = await rulebooks_svc.rule_detail(uid, rule)
payload["verified"] = bool(still_true)
return jsonify(payload)
+25 -1
View File
@@ -62,14 +62,38 @@ async def create_system_route(project_id: int):
data = await request.get_json() or {}
if not (data.get("name") or "").strip():
return jsonify({"error": "name is required"}), 400
# The same gate the MCP door enforces. It lived only in the tool layer
# until now, which is exactly how the web UI shipped without gates the
# agent surface had (#2482) — one service call, one answer (rule 33).
assessment = await systems_svc.assess_system_name(uid, project_id, data["name"])
duplicate = assessment["duplicate"]
if duplicate and not data.get("force"):
return jsonify({
"duplicate": True,
"existing_id": duplicate["id"],
"error": (
f"{duplicate['name']}” already covers this area in this "
"project. Tag records to it, or rename it if its charter has "
"moved on — a second System with the same name splits the "
"area's records across two piles."
),
}), 409
canonical = assessment["canonical"]
# Exact is mechanical and applied; overlap is a judgment call and is only
# offered back for the form to present.
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
system = await systems_svc.create_system(
uid, project_id=project_id, name=data["name"],
description=data.get("description"), color=data.get("color"),
order_index=data.get("order_index", 0),
canonical_id=data.get("canonical_id") or applied,
)
if system is None:
return jsonify({"error": "Permission denied"}), 403
return jsonify(system.to_dict()), 201
out = system.to_dict()
if canonical and canonical["basis"] == "overlap" and not system.canonical_id:
out["canonical_suggestion"] = canonical
return jsonify(out), 201
@systems_bp.route("/<int:project_id>/systems/<int:system_id>", methods=["GET"])
+11
View File
@@ -13,6 +13,7 @@ from scribe.services.notes import (
list_notes,
update_note,
)
from scribe.services.notes import minted_kind as notes_minted_kind
from scribe.services.note_usage import record_pulled
from scribe.services.planning import start_planning as svc_start_planning
from scribe.services.recurrence import calculate_next_due, validate_recurrence_rule
@@ -239,6 +240,16 @@ async def update_task_route(task_id: int):
if "tags" in data:
fields["tags"] = data["tags"]
# Re-filing a task's kind. The editor has always SENT this field and the
# route silently dropped it — reporting "Task saved" and reverting on the
# next load (#3129). Validated at the same layer as status and priority so
# an unrecognised value is a 400 rather than a database CHECK violation.
if "kind" in data:
try:
fields["task_kind"] = notes_minted_kind(data["kind"])
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
for key in ("project_id", "milestone_id", "parent_id", "arose_from_id"):
if key in data:
fields[key] = data[key]
+247 -11
View File
@@ -11,6 +11,8 @@ from scribe.models.note_supersession import NoteSupersession
from scribe.models.note_version import NoteVersion
from scribe.models.design_system import DesignSystem, DesignToken
from scribe.models.note_usage import NoteUsageEvent
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.rulebook import RuleRelation, rule_systems as rule_systems_t
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
from scribe.models.project import Project
from scribe.models.repo_binding import RepoBinding
@@ -19,6 +21,7 @@ from scribe.models.rulebook import (
Rulebook,
RulebookTopic,
project_rule_suppressions,
project_rulebook_exclusions,
project_rulebook_subscriptions,
project_topic_suppressions,
)
@@ -45,8 +48,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,17 +65,24 @@ _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",
# v7 (2026-08): the shape ledger (#2787); v8: its history (#2793).
"code_shapes", "code_shape_events", "code_shape_uses",
# v9 (2026-08): the global area catalog (milestone 307). Global, not
# user-scoped, so it rides in EVERY export — including a single-user
# one, whose Systems would otherwise restore unmapped.
"canonical_systems",
# v10 (2026-08): a rule's area tag and its typed edges (milestone 307).
"rule_systems", "rule_relations",
]
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
# explicit rather than silent. ACL (groups/shares) is a coherent follow-up;
# note_embeddings are derived (regenerated from note bodies); api_keys are
# note_embeddings and rule_embeddings are derived (regenerated at startup
# from the records themselves, which is also how a chunker bump is handled); api_keys are
# sensitive credentials; retrieval_logs is observational telemetry that nothing
# reads for correctness and that grows per query; the rest are
# transient/operational.
@@ -80,7 +92,7 @@ _BACKED_UP = [
# like coverage while naming nothing the schema could confirm.
_NOT_INCLUDED = [
"groups", "group_memberships", "project_shares", "note_shares",
"api_keys", "note_embeddings", "app_logs", "notifications",
"api_keys", "note_embeddings", "rule_embeddings", "app_logs", "notifications",
"invitation_tokens", "password_reset_tokens", "user_profiles",
"retrieval_logs",
# Sensitive credentials, same reasoning as api_keys: a backup that carries
@@ -89,6 +101,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",
]
@@ -96,6 +112,18 @@ def _dt(val: str | None) -> datetime:
return datetime.fromisoformat(val) if val else datetime.now(timezone.utc)
def _dt_or_none(val: str | None) -> datetime | None:
"""Like _dt, but keeps an absent timestamp absent.
_dt substitutes now() because created_at/updated_at must not be null.
For a nullable column that MEANS something by being empty, that default
is a lie: a rule nobody ever verified would restore looking verified at
the moment of the restore, and drop straight to the bottom of the sweep
it should have topped.
"""
return datetime.fromisoformat(val) if val else None
def _d(val: str | None) -> date | None:
return date.fromisoformat(val) if val else None
@@ -112,16 +140,37 @@ 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.
def _system_rows(rows) -> list[dict]:
def _canonical_system_rows(rows) -> list[dict]:
"""The global area catalog. Carried WITHOUT ids: a restore matches on slug,
so a target install that already seeded the standard vocabulary reuses its
own rows and only gains the entries an admin added here."""
return [
{
"name": r.name, "slug": r.slug, "description": r.description,
"order_index": r.order_index,
}
for r in rows
]
def _system_rows(rows, canonical_slugs: dict[int, str]) -> list[dict]:
"""A project's Systems. The canonical mapping travels as a SLUG, not an id
— the catalog is global and its ids are per-install, so an id would restore
pointing at whatever area happened to land on that number."""
return [
{
"id": r.id, "user_id": r.user_id, "project_id": r.project_id,
"name": r.name, "description": r.description, "color": r.color,
"status": r.status, "order_index": r.order_index,
"canonical_slug": canonical_slugs.get(r.canonical_id or 0),
}
for r in rows
]
@@ -219,6 +268,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(),
}
@@ -319,12 +370,36 @@ def _topic_rows(rows) -> list[dict]:
]
def _rule_system_rows(rows) -> list[dict]:
"""A rule's area tags, carried by canonical SLUG for the same reason the
Systems are: the catalog is global and its ids are per-install."""
return [{"rule_id": rule_id, "canonical_slug": slug} for rule_id, slug in rows]
def _rule_relation_rows(rows) -> list[dict]:
"""The typed edges between rules. Carried because they are a JUDGEMENT —
someone decided these two fail together, or that one supersedes the other,
and nothing in either rule's text records the decision. Lose them and a
split rule silently starts arriving half at a time again."""
return [
{
"from_rule_id": r.from_rule_id, "to_rule_id": r.to_rule_id,
"kind": r.kind, "note": r.note,
}
for r in rows
]
def _rule_rows(rows) -> list[dict]:
return [
{
"id": r.id, "topic_id": r.topic_id, "project_id": r.project_id,
"title": r.title, "statement": r.statement, "why": r.why,
"how_to_apply": r.how_to_apply, "order_index": r.order_index,
"when_to_apply": r.when_to_apply, "tier": r.tier,
"verify_with": r.verify_with, "expires_when": r.expires_when,
"verified_at": r.verified_at.isoformat() if r.verified_at else None,
"arose_from_id": r.arose_from_id,
"created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat(),
}
@@ -350,6 +425,15 @@ async def export_full_backup() -> dict:
)).scalars().all()
settings = (await session.execute(select(Setting))).scalars().all()
systems = (await session.execute(select(System))).scalars().all()
canonical_systems = (await session.execute(
select(CanonicalSystem).where(CanonicalSystem.deleted_at.is_(None))
.order_by(CanonicalSystem.order_index)
)).scalars().all()
rule_system_rows = (await session.execute(
select(rule_systems_t.c.rule_id, CanonicalSystem.slug)
.join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id)
)).all()
rule_relations = (await session.execute(select(RuleRelation))).scalars().all()
record_systems = (await session.execute(select(RecordSystem))).scalars().all()
supersessions = (
await session.execute(select(NoteSupersession))
@@ -383,6 +467,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,7 +494,13 @@ 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),
"systems": _system_rows(systems),
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
"canonical_systems": _canonical_system_rows(canonical_systems),
"rule_systems": _rule_system_rows(rule_system_rows),
"rule_relations": _rule_relation_rows(rule_relations),
"systems": _system_rows(
systems, {c.id: c.slug for c in canonical_systems}
),
"record_systems": _record_system_rows(record_systems),
"design_systems": _design_system_rows(design_systems),
"design_tokens": _design_token_rows(design_tokens),
@@ -450,6 +543,12 @@ async def export_user_backup(user_id: int) -> dict:
systems = (await session.execute(
select(System).where(System.user_id == user_id)
)).scalars().all()
# Global: taken whole even in a per-user export, because the Systems
# above reference it and a partial catalog restores partial mappings.
canonical_systems = (await session.execute(
select(CanonicalSystem).where(CanonicalSystem.deleted_at.is_(None))
.order_by(CanonicalSystem.order_index)
)).scalars().all()
system_ids = [sy.id for sy in systems]
note_ids = [n.id for n in notes]
# Scoped by the user's SYSTEMS, not their notes: a shared note carrying
@@ -516,6 +615,20 @@ async def export_user_backup(user_id: int) -> dict:
rules = (await session.execute(
select(Rule).where(or_(*rule_filters))
)).scalars().all() if rule_filters else []
# Scoped to the rules this export already carries: an edge whose far
# end is absent would restore pointing at nothing.
_rule_ids = [r.id for r in rules]
rule_system_rows = (await session.execute(
select(rule_systems_t.c.rule_id, CanonicalSystem.slug)
.join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id)
.where(rule_systems_t.c.rule_id.in_(_rule_ids))
)).all() if _rule_ids else []
rule_relations = (await session.execute(
select(RuleRelation).where(
RuleRelation.from_rule_id.in_(_rule_ids),
RuleRelation.to_rule_id.in_(_rule_ids),
)
)).scalars().all() if _rule_ids else []
if project_ids:
subscriptions = (await session.execute(
select(project_rulebook_subscriptions).where(
@@ -532,8 +645,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,7 +678,13 @@ 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),
"systems": _system_rows(systems),
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
"canonical_systems": _canonical_system_rows(canonical_systems),
"rule_systems": _rule_system_rows(rule_system_rows),
"rule_relations": _rule_relation_rows(rule_relations),
"systems": _system_rows(
systems, {c.id: c.slug for c in canonical_systems}
),
"record_systems": _record_system_rows(record_systems),
"design_systems": _design_system_rows(design_systems),
"design_tokens": _design_token_rows(design_tokens),
@@ -670,11 +794,12 @@ 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,
"code_shape_uses": 0,
"code_shape_uses": 0, "canonical_systems": 0,
"rule_systems": 0, "rule_relations": 0,
}
async with async_session() as session:
@@ -891,6 +1016,24 @@ async def _restore_v2(data: dict) -> dict:
statement=r_data.get("statement", ""),
why=r_data.get("why") or None,
how_to_apply=r_data.get("how_to_apply") or None,
when_to_apply=r_data.get("when_to_apply") or None,
# A file written before migration 0088 has no tier. always_on
# is the pre-0088 behaviour, so an old backup restores rules
# that bind exactly as they did when it was taken.
tier=r_data.get("tier") or "always_on",
verify_with=r_data.get("verify_with") or None,
expires_when=r_data.get("expires_when") or None,
# Restored as-is, NOT reset to null. `verified_at` records
# when someone last ran the check; a restore does not make
# that untrue, and clearing it would put every constraint at
# the top of the sweep with nothing having actually changed.
verified_at=_dt_or_none(r_data.get("verified_at")),
# Remapped through note_id_map like every other note edge.
# Exported since 0088 but dropped on the way back in until
# milestone 312 — a restore silently lost every rule's
# provenance link. SET NULL semantics apply here too: a
# source note that didn't restore leaves the rule intact.
arose_from_id=note_id_map.get(r_data.get("arose_from_id") or 0),
order_index=r_data.get("order_index", 0),
created_at=_dt(r_data.get("created_at")),
updated_at=_dt(r_data.get("updated_at")),
@@ -933,11 +1076,72 @@ 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.
# 15. Systems
system_id_map: dict[int, int] = {}
# 14c. The global area catalog, matched on SLUG. This install already
# has the standard vocabulary from its migrations, so the common case
# adds nothing and simply learns which local id each slug is; only an
# entry an admin added on the source instance is created here. Runs
# BEFORE systems, which resolve their mapping through this map.
canonical_id_by_slug: dict[str, int] = {}
existing_canonical = (await session.execute(
select(CanonicalSystem).where(CanonicalSystem.deleted_at.is_(None))
)).scalars().all()
for entry in existing_canonical:
canonical_id_by_slug[entry.slug] = entry.id
for cs_data in data.get("canonical_systems", []):
slug = cs_data.get("slug") or ""
if not slug or slug in canonical_id_by_slug:
continue
entry = CanonicalSystem(
name=cs_data.get("name", ""), slug=slug,
description=cs_data.get("description"),
order_index=cs_data.get("order_index", 0),
)
session.add(entry)
await session.flush()
canonical_id_by_slug[slug] = entry.id
stats["canonical_systems"] += 1
# 14d. A rule's area tags and its typed edges. Runs HERE, not beside the
# rules in section 11, because it needs both maps: the rule ids from
# there and the canonical slugs from 14c just above.
for rs in data.get("rule_systems", []):
mapped_rule = rule_id_map.get(rs.get("rule_id", 0))
canonical_id = canonical_id_by_slug.get(rs.get("canonical_slug") or "")
if mapped_rule is None or canonical_id is None:
continue
await session.execute(rule_systems_t.insert().values(
rule_id=mapped_rule, canonical_id=canonical_id,
))
stats["rule_systems"] += 1
for rr in data.get("rule_relations", []):
mapped_from = rule_id_map.get(rr.get("from_rule_id", 0))
mapped_to = rule_id_map.get(rr.get("to_rule_id", 0))
if mapped_from is None or mapped_to is None or mapped_from == mapped_to:
continue
session.add(RuleRelation(
from_rule_id=mapped_from, to_rule_id=mapped_to,
kind=rr.get("kind", "co_surfaces"), note=rr.get("note") or None,
))
stats["rule_relations"] += 1
# 15. Systems
for sy_data in data.get("systems", []):
mapped_uid = user_id_map.get(sy_data.get("user_id", 0))
mapped_pid = project_id_map.get(sy_data.get("project_id", 0))
@@ -950,6 +1154,9 @@ async def _restore_v2(data: dict) -> dict:
color=sy_data.get("color"),
status=sy_data.get("status", "active"),
order_index=sy_data.get("order_index", 0),
# An unknown slug restores UNMAPPED rather than failing: the
# System and its records are the payload, the mapping is an aid.
canonical_id=canonical_id_by_slug.get(sy_data.get("canonical_slug") or ""),
)
session.add(system)
await session.flush()
@@ -1137,6 +1344,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)
+290
View File
@@ -0,0 +1,290 @@
"""The global canonical area vocabulary, and the mapping from a project's
Systems onto it (milestone 307 step 1, decision note 3026).
A `System` is per-project. Nothing outside a project can reference one, so a
rule that spans projects has no way to say "this is about CI" without chaining
itself to one project's row. `CanonicalSystem` is that join key, and it is
GLOBAL — no `user_id`, so a shared project inherits the vocabulary instead of
re-earning it.
Two rules govern everything here:
- **Associate, never rewrite.** Mapping a System sets `systems.canonical_id`
and nothing else. The local name stays whatever the project calls the area,
and `record_systems` is never touched — no record's tags move.
- **Propose, never decide.** An exact slug hit is mechanical and maps on
request; anything short of that is a PROPOSAL a human confirms. "CI &
Release" vs "CI & runners" is a judgment call, and the cost of guessing it
wrong silently is a rule surfacing in the wrong project.
Reads are open to any authenticated caller (the catalog is shared vocabulary,
not user data). Writes to the catalog itself are admin-only: a global table
that anyone can extend is how a shared vocabulary stops being shared.
"""
import logging
import re
from datetime import datetime, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.system import System
from scribe.models.user import User
from scribe.services import access
logger = logging.getLogger(__name__)
# Tokens that carry no meaning for matching — "&" becomes "and" before the
# split, so it would otherwise dominate the overlap score of every pair.
_NOISE_TOKENS = frozenset({"and", "the", "a", "of"})
_NON_ALNUM = re.compile(r"[^a-z0-9]+")
def canonical_slug(name: str) -> str:
"""The match key for an area name — NOT a display value.
Folds exactly the spelling differences that produced three names for one
area on the author's instance: `CI & Release`, `CI and Release` and
`CI & release` all slug to `ci-and-release`, so they map mechanically.
A real difference survives: `CI & runners` slugs to `ci-and-runners` and
goes through the proposal path where a human decides.
"""
lowered = name.strip().lower().replace("&", " and ")
return "-".join(_NON_ALNUM.sub(" ", lowered).split())
def _tokens(slug: str) -> frozenset[str]:
return frozenset(slug.split("-")) - _NOISE_TOKENS
async def _is_admin(user_id: int) -> bool:
async with async_session() as session:
role = await session.scalar(select(User.role).where(User.id == user_id))
return role == "admin"
async def list_canonical_systems() -> list[CanonicalSystem]:
"""The whole catalog, in display order. Global — no ownership filter."""
async with async_session() as session:
result = await session.execute(
select(CanonicalSystem)
.where(CanonicalSystem.deleted_at.is_(None))
.order_by(CanonicalSystem.order_index.asc(), CanonicalSystem.name.asc())
)
return list(result.scalars().all())
async def get_canonical_system(canonical_id: int) -> CanonicalSystem | None:
async with async_session() as session:
entry = await session.get(CanonicalSystem, canonical_id)
return entry if entry is not None and entry.deleted_at is None else None
async def find_by_name(name: str) -> CanonicalSystem | None:
"""The exact-slug lookup — the mechanical half of matching."""
slug = canonical_slug(name)
if not slug:
return None
async with async_session() as session:
return await session.scalar(
select(CanonicalSystem).where(
CanonicalSystem.slug == slug,
CanonicalSystem.deleted_at.is_(None),
)
)
def _overlap(local: frozenset[str], other: frozenset[str]) -> float:
return len(local & other) / max(len(local | other), 1)
async def best_overlap(name: str, catalog: list | None = None) -> dict | None:
"""The closest catalog entry that shares a meaningful word, or None.
The ONE scorer behind both offers: the create-time suggestion and the
review surface. Two scorers would eventually disagree about which area a
name resembles, and the operator would be asked one question at create
time and a different one at review.
The threshold is any shared meaningful word, deliberately generous: a
wrong offer costs one dismissal, a missing one costs a mapping nobody
thinks to make again. Nothing here ever applies — `overlap` is always an
offer (see propose_mappings).
"""
slug = canonical_slug(name)
if not slug:
return None
local = _tokens(slug)
if not local:
return None
# A caller already holding the catalog passes it: this runs once per
# unmapped System in the review sweep, and re-reading the table each time
# would make an N+1 out of a report.
if catalog is None:
catalog = await list_canonical_systems()
best, best_score = None, 0.0
for entry in catalog:
score = _overlap(local, _tokens(entry.slug))
if score > best_score:
best, best_score = entry, score
if best is None or best_score <= 0:
return None
return {
"id": best.id, "name": best.name,
"basis": "overlap", "score": round(best_score, 3),
}
async def create_canonical_system(
user_id: int, name: str, description: str | None = None,
) -> CanonicalSystem | dict | None:
"""Add an area to the global catalog. Admin only.
Duplicate-gated on the SLUG, not the raw name, so "CI and Release" cannot
be added alongside "CI & Release" — that is the drift this table exists to
end. Returns the existing entry's id instead of creating a second one.
"""
if not await _is_admin(user_id):
return None
slug = canonical_slug(name)
if not slug:
return None
existing = await find_by_name(name)
if existing is not None:
return {
"duplicate": True,
"existing_id": existing.id,
"message": (
f"'{existing.name}' (#{existing.id}) already covers this area — "
f"both names reduce to '{slug}'. Map Systems to it, or "
"update_canonical_system if the charter needs revising."
),
}
async with async_session() as session:
highest = await session.scalar(
select(CanonicalSystem.order_index)
.order_by(CanonicalSystem.order_index.desc())
.limit(1)
)
entry = CanonicalSystem(
name=" ".join(name.split()),
slug=slug,
description=description,
order_index=(highest or 0) + 1,
)
session.add(entry)
await session.commit()
await session.refresh(entry)
return entry
async def update_canonical_system(
user_id: int, canonical_id: int, **fields: object,
) -> CanonicalSystem | None:
"""Rename or re-charter a catalog entry. Admin only.
A rename recomputes the slug — the display name and the match key must not
be allowed to disagree, or the exact-match path silently stops finding it.
"""
if not await _is_admin(user_id):
return None
allowed = {"name", "description", "order_index"}
async with async_session() as session:
entry = await session.get(CanonicalSystem, canonical_id)
if entry is None or entry.deleted_at is not None:
return None
for key, value in fields.items():
if key in allowed and value is not None:
setattr(entry, key, value)
if "name" in fields and fields["name"]:
entry.name = " ".join(str(fields["name"]).split())
entry.slug = canonical_slug(entry.name)
entry.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(entry)
return entry
async def set_system_canonical(
user_id: int, system_id: int, canonical_id: int | None,
) -> System | None:
"""Map (or unmap) one project System onto a catalog entry.
Authorised by the PROJECT, not the catalog: mapping changes the project's
row, so project write access is the right gate (rule 78 — never a bare
owner filter). Passing None clears the mapping.
Touches `canonical_id` and nothing else — the System's own name, charter
and record associations are left exactly as they are.
"""
if canonical_id is not None and await get_canonical_system(canonical_id) is None:
return None
async with async_session() as session:
system = await session.get(System, system_id)
if system is None or system.deleted_at is not None:
return None
if not await access.can_write_project(user_id, system.project_id):
return None
system.canonical_id = canonical_id
system.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(system)
return system
async def propose_mappings(user_id: int, project_id: int) -> list[dict]:
"""Suggest a catalog entry for each of a project's UNMAPPED Systems.
Returns proposals, never applied changes — `set_system_canonical` is the
only thing that writes. Each carries a `basis` so the reviewer knows what
they are approving:
- `exact` — the two names reduce to the same slug. Mechanical.
- `overlap` — they share a meaningful word ("CI & runners" / "CI &
Release"). A judgment call, and the reason this is a proposal at all.
A System with no plausible match simply gets no proposal: unmapped is a
perfectly good resting state, so silence here is an answer, not a gap.
"""
if not await access.can_read_project(user_id, project_id):
return []
catalog = await list_canonical_systems()
if not catalog:
return []
async with async_session() as session:
result = await session.execute(
select(System).where(
System.project_id == project_id,
System.canonical_id.is_(None),
System.deleted_at.is_(None),
).order_by(System.order_index.asc(), System.created_at.asc())
)
systems = list(result.scalars().all())
by_slug = {entry.slug: entry for entry in catalog}
proposals: list[dict] = []
for system in systems:
slug = canonical_slug(system.name)
if not slug:
continue
exact = by_slug.get(slug)
if exact is not None:
match = {"id": exact.id, "name": exact.name, "basis": "exact", "score": 1.0}
else:
# Same scorer the create-time offer uses, so the two surfaces can
# never name different areas for one System.
match = await best_overlap(system.name, catalog)
if match is None:
continue
proposals.append({
"system_id": system.id,
"system_name": system.name,
"canonical_id": match["id"],
"canonical_name": match["name"],
"basis": match["basis"],
"score": match["score"],
})
proposals.sort(key=lambda p: (-p["score"], p["system_name"]))
return proposals
+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
+196 -1
View File
@@ -16,13 +16,18 @@ import os
from collections.abc import Sequence
from typing import TYPE_CHECKING
from sqlalchemy import delete, or_, select
from scribe.models import async_session
from scribe.models.embedding import NoteEmbedding
from scribe.models.embedding import NoteEmbedding, RuleEmbedding
from scribe.models.note import Note
from scribe.services.access import notes_visibility_clause
if TYPE_CHECKING: # resolves the Rule forward ref without importing at runtime
from scribe.models.rulebook import Rule
logger = logging.getLogger(__name__)
# Minimum cosine similarity to include a note in context results.
@@ -612,3 +617,193 @@ async def backfill_note_embeddings() -> None:
await asyncio.sleep(0.05) # gentle pacing
logger.info("Embedding backfill complete: %d/%d notes embedded", success, len(notes_to_embed))
# ── Rules (milestone 307, note 3026) ────────────────────────────────────
def rule_document(
title: str | None, statement: str | None, when_to_apply: str | None,
) -> tuple[str | None, str | None]:
"""The (title, body) a rule is EMBEDDED as — trigger first, `why` never.
Both halves of this are measured, not guessed (note 2485). That pass found
the snippet was the only sharp record in the corpus — a 0.153 top-to-second
gap against 0.0100.023 for everything else — and that the cause was its
SHAPE: `{name}{when_to_use}` as the title and `**When to use:** …`
repeated in the body, so purpose appears twice in a short document and
dominates the vector. This mirrors that exactly.
And it excludes `why` on the same evidence. `why` is dated incident
narrative — rule 46's runs to 4,300 characters of it — and long,
multi-topic prose is precisely what made sixteen dev-logs mutually
indistinguishable: the average lands on the centroid of "development",
which every one of them shares. Adding `why` would not give the vector more
to work with; it would give every rule the same thing to work with.
A rule with no trigger yet degrades to title + statement. It still embeds,
just less sharply — which is an argument for backfilling triggers, not an
argument for padding the document with whatever text is lying around.
"""
trigger = (when_to_apply or "").strip()
name = (title or "").strip()
body = (statement or "").strip()
if not trigger:
return name or None, body or None
return (
f"{name}{trigger}" if name else trigger,
f"When to apply: {trigger}\n\n{body}" if body else f"When to apply: {trigger}",
)
async def upsert_rule_embedding(
rule_id: int, title: str | None, statement: str | None,
when_to_apply: str | None = None,
) -> None:
"""Chunk, embed and persist a rule's vectors. Safe to fire-and-forget.
The note twin's contract, for the same reasons: the document is built HERE
so the write path, the backfill and any re-embed share one definition, and
replacement is atomic per rule so a concurrent read sees the old chunk set
or the new one, never a mixture.
"""
doc_title, doc_body = rule_document(title, statement, when_to_apply)
chunks = chunk_document(doc_title, doc_body)
try:
if not chunks:
async with async_session() as session:
await session.execute(
delete(RuleEmbedding).where(RuleEmbedding.rule_id == rule_id)
)
await session.commit()
return
except Exception:
logger.warning("Failed to clear embedding for rule %d", rule_id, exc_info=True)
return
try:
vectors = await get_embeddings(chunks)
except Exception:
logger.debug("Skipping embedding for rule %d — embedder unavailable", rule_id)
return
try:
async with async_session() as session:
await session.execute(
delete(RuleEmbedding).where(RuleEmbedding.rule_id == rule_id)
)
for index, (chunk, vector) in enumerate(zip(chunks, vectors)):
session.add(
RuleEmbedding(
rule_id=rule_id,
chunk_index=index,
embedding=vector,
chunk_text=chunk,
chunker_version=CHUNKER_VERSION,
)
)
await session.commit()
except Exception:
logger.warning("Failed to persist embedding for rule %d", rule_id, exc_info=True)
async def semantic_search_rules(
user_id: int,
query: str,
limit: int = 5,
threshold: float = _SIMILARITY_THRESHOLD,
tier: str | None = None,
) -> list[tuple[float, "Rule"]]:
"""Return up to *limit* (score, rule) pairs most relevant to *query*.
Scoped by OWNERSHIP — a rule is the caller's if they own its rulebook or
its project. Deliberately not filtered to what currently BINDS a given
project: this answers "is there a rule about this", which a person asking
wants answered across their whole rulebook. Deciding which rules bind where
is the surfacing question, and it has its own machinery
(get_applicable_rules) rather than a second, subtly different copy here.
`tier` narrows to one tier. The write-path hint passes "conditional",
because an always-on rule is ALREADY in the session — surfacing it again as
a suggestion is pure noise, and noise on a hint that fires on every write
is how a hint gets ignored.
Collapses to best-chunk-per-rule like the note search, so a long rule split
across chunks competes once rather than crowding the results with itself.
Returns an empty list if the embedder is unavailable or on any error.
"""
from scribe.models.project import Project
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
if not query or not query.strip():
return []
try:
query_vec = await get_embedding(query)
except Exception:
logger.debug("Rule search skipped — embedder unavailable")
return []
max_distance = min(2.0, max(0.0, 1.0 - threshold))
distance = RuleEmbedding.embedding.cosine_distance(query_vec)
try:
async with async_session() as session:
rows = (await session.execute(
select(Rule, distance.label("distance"))
.select_from(RuleEmbedding)
.join(Rule, RuleEmbedding.rule_id == Rule.id)
.outerjoin(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.outerjoin(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.outerjoin(Project, Rule.project_id == Project.id)
.where(
Rule.deleted_at.is_(None),
distance <= max_distance,
# topic_id XOR project_id, so exactly one arm can match.
or_(
Rulebook.owner_user_id == user_id,
Project.user_id == user_id,
),
*( [Rule.tier == tier] if tier else [] ),
)
# Overfetch so collapsing chunks to their best row still fills
# the page — the same reason the note search overfetches.
.order_by(distance)
.limit(limit * _CHUNK_OVERFETCH)
)).all()
except Exception:
logger.warning("Rule semantic search failed", exc_info=True)
return []
best: dict[int, tuple[float, object]] = {}
for rule, dist in rows:
score = 1.0 - float(dist)
if rule.id not in best or score > best[rule.id][0]:
best[rule.id] = (score, rule)
ranked = sorted(best.values(), key=lambda pair: pair[0], reverse=True)
return ranked[:limit]
async def backfill_rule_embeddings() -> None:
"""Embed rules that have no current vectors. Runs at startup beside the
note backfill; a CHUNKER_VERSION bump re-embeds rather than wiping."""
from scribe.models.rulebook import Rule
try:
async with async_session() as session:
current = select(RuleEmbedding.rule_id).where(
RuleEmbedding.chunker_version == CHUNKER_VERSION
)
stale = (await session.execute(
select(Rule.id, Rule.title, Rule.statement, Rule.when_to_apply)
.where(Rule.deleted_at.is_(None), Rule.id.notin_(current))
)).all()
except Exception:
logger.warning("Rule embedding backfill: failed to query rules", exc_info=True)
return
if not stale:
logger.info("Rule embedding backfill: all rules current at chunker v%d", CHUNKER_VERSION)
return
logger.info("Rule embedding backfill: embedding %d rule(s)", len(stale))
for rule_id, title, statement, when_to_apply in stale:
await upsert_rule_embedding(rule_id, title, statement, when_to_apply)
+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>)"
),
}
+46 -1
View File
@@ -5,7 +5,7 @@ from datetime import date, datetime, timezone
from sqlalchemy import func, or_, select, text
from scribe.models import async_session
from scribe.models.note import Note, TaskPriority, TaskStatus
from scribe.models.note import Note, TaskKind, TaskPriority, TaskStatus
logger = logging.getLogger(__name__)
@@ -366,6 +366,37 @@ async def get_or_create_note_by_title(user_id: int, title: str) -> Note:
return await create_note(user_id, title=title)
# Kinds a caller may MINT. Narrower than what the COLUMN holds: `plan` is a
# valid stored value — historical plan-tasks carry it and must stay writable —
# but plans became milestones in 0066, so no door hands out a new one. The
# CHECK whitelist and this policy answer different questions, which is why
# they are deliberately not the same list.
MINTABLE_KINDS = ("work", "issue", "spike")
def minted_kind(kind: str) -> str:
"""Validate a kind a caller is asking to WRITE, or raise saying why.
Lives here rather than in either door so both share one copy: the REST
route cannot import an MCP tool module, and a second spelling of this
list is how the two doors would come to disagree.
Raises rather than falling back to 'work'. A silently-corrected kind is
the defect this exists to end (#3129: the editor's Kind select reported
success and changed nothing), and a caller naming a kind we do not know
has a wrong idea that an error corrects and a default hides.
"""
if kind in MINTABLE_KINDS:
return kind
if kind == "plan":
raise ValueError(
"kind='plan' is retired — plans are milestones. Call "
"start_planning(project_id, title) to begin one. Existing "
"plan-tasks keep the value and stay editable."
)
raise ValueError(f"kind must be one of {MINTABLE_KINDS}, got {kind!r}")
async def update_note(user_id: int, note_id: int, **fields: object) -> Note | None:
async with async_session() as session:
result = await session.execute(
@@ -391,6 +422,20 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
value = TaskPriority(value).value
except ValueError:
raise ValueError(f"Invalid priority: {value!r}. Must be one of: {[p.value for p in TaskPriority]}")
elif key == "task_kind" and isinstance(value, str):
# Same shape as status/priority above, and for the same
# reason: a kind the column will refuse should fail here with
# a readable message, not as a CheckViolationError from the
# database. Before this, `task_kind` reached setattr through
# the hasattr guard with no validation at all — but no door
# ever offered it, so a task's kind was write-once (#3129).
try:
value = TaskKind(value).value
except ValueError:
raise ValueError(
f"Invalid kind: {value!r}. Must be one of: "
f"{[k.value for k in TaskKind]}"
)
elif key == "tags" and isinstance(value, list):
value = _normalize_tags(value)
setattr(note, key, value)
+133 -4
View File
@@ -30,7 +30,7 @@ from scribe.services import rulebooks as rulebooks_svc
from scribe.services import shape_ledger as shape_ledger_svc
from scribe.services import snippets as snippets_svc
from scribe.services.access import label_shared_items, owner_names_for
from scribe.services.embeddings import semantic_search_notes
from scribe.services.embeddings import semantic_search_notes, semantic_search_rules
from scribe.services.note_usage import record_surfaced
from scribe.services.supersession import superseded_ids
from scribe.services.retrieval_telemetry import record_retrieval
@@ -706,6 +706,8 @@ 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,
exclude_rule_ids: list[int] | None = None,
) -> dict:
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
@@ -765,7 +767,8 @@ 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": [],
"rule_ids": []}
path = (path or "").strip()
if not cfg["enabled"] or not path:
return empty
@@ -935,7 +938,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 +1019,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
@@ -1020,6 +1038,50 @@ async def build_write_path_hint(
for arm, ids in by_arm.items():
record_surfaced(user_id=user_id, note_ids=ids, source=arm)
# ── Standing rules that may apply here (milestone 307) ──────────────
#
# A SUGGESTION, not a binding surface, and the distinction is the design
# (D7): a rule BINDS by being tagged to an area the project works in,
# resolved deterministically at enter_project. This arm reaches for
# something weaker and still useful — a conditional rule whose trigger
# resembles what is being written, noticed at the moment it is relevant
# rather than by being resident in every session.
#
# CONDITIONAL ONLY. An always-on rule is already in the session; repeating
# it here would be noise, and noise on a hint that fires on every write is
# how a hint gets ignored.
#
# Fails open like every other arm: a rule hint must never break a write.
rule_ids: list[int] = []
try:
already = set(exclude_rule_ids or [])
hits = await semantic_search_rules(
user_id, code or path, limit=2,
threshold=cfg["threshold"], tier="conditional",
)
fresh = [(score, rule) for score, rule in hits if rule.id not in already]
for _score, rule in fresh:
trigger = (rule.when_to_apply or "").strip()
lines.append(
f"Standing rule that may apply here — \u201c{rule.title}\u201d"
+ (f" ({trigger})" if trigger else "")
+ f". Read it with get_rule({rule.id}) before deciding it "
"does not apply; it is not in this session's loaded set."
)
rule_ids.append(rule.id)
if fresh:
# retrieval_logs, NOT note_usage_events: that table's ids are
# remapped on a backup restore, so a rule id there would return
# attached to whatever note took that number. This one is never
# restored, and `source` already separates the surfaces.
record_retrieval(
user_id=user_id, source="write_path_rule", query=code or path,
threshold=cfg["threshold"], limit=2, project_id=project_id,
is_task=None, results=fresh,
)
except Exception:
logger.debug("write-path rule arm failed", exc_info=True)
return {
"context": "\n".join(lines),
"note_ids": note_ids,
@@ -1027,9 +1089,63 @@ async def build_write_path_hint(
"config": cfg,
"stamped": stamped,
"divergence": divergence,
"derive": derive,
"derive_keys": [d["key"] for d in derive],
"rule_ids": rule_ids,
}
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 +1213,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 +1242,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:
+218 -1
View File
@@ -17,9 +17,16 @@ from __future__ import annotations
import asyncio
import logging
from typing import Any
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__)
@@ -102,11 +109,19 @@ def record_retrieval(
limit: int | None,
project_id: int | None,
is_task: bool | None,
results: list[tuple[float, Note]],
results: list[tuple[float, Any]],
duration_ms: float | None = None,
) -> None:
"""Fire-and-forget: record one retrieval call.
`results` needs only `.id` on each record, which is why it is not typed to
Note: rules are retrieved too (milestone 307) and land here rather than in
note_usage_events. That table's ids are REMAPPED on a backup restore, so a
rule id written into it would come back attached to whatever note happened
to take that number — silent corruption of the very evidence this exists to
provide. retrieval_logs is not restored at all, so it has no such hazard,
and `source` already distinguishes the surfaces.
Builds the payload inline (synchronously) then schedules the insert so the
caller returns immediately. Never raises — telemetry must not affect search.
"""
@@ -135,3 +150,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
+736 -26
View File
@@ -8,11 +8,13 @@ depending on the caller's needs (mirroring services/events.py pattern).
from __future__ import annotations
import logging
from collections.abc import Iterable
from typing import Optional
from sqlalchemy import select
from sqlalchemy import and_, delete as sql_delete, insert, or_, select
from scribe.models import async_session
from scribe.models.system import System
from scribe.models.rulebook import Rulebook
logger = logging.getLogger(__name__)
@@ -223,7 +225,7 @@ async def delete_topic(topic_id: int, user_id: int) -> None:
# ── Rule CRUD ──────────────────────────────────────────────────────────
from scribe.models.rulebook import Rule
from scribe.models.rulebook import Rule, RuleRelation, rule_systems
async def _assert_topic_owned(session, topic_id: int, user_id: int) -> None:
@@ -280,9 +282,198 @@ async def _assert_rulebook_rule_owned(session, rule_id: int, user_id: int) -> No
raise ValueError(f"rule {rule_id} not found or not a rulebook rule")
# The vocabularies migration 0088's CHECK constraints enforce. Named here so
# a caller can be corrected before the database refuses it (rule 36 keeps the
# two in step; this keeps the error readable).
TIERS = ("always_on", "conditional")
RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
# The rule columns that are nullable, and therefore the ones where EMPTY has
# to mean empty. A write that stores "" leaves a column that is not NULL and
# not content — `verify_with IS NOT NULL` would then be true for a rule with
# no check, and the staleness sweep would list rules it should never see.
# Normalising here, at the one service seam, is what makes "unset" a single
# state instead of two that read alike through to_dict's `or ""`.
NULLABLE_RULE_TEXT = (
"why", "how_to_apply", "when_to_apply", "verify_with", "expires_when",
)
def _valid_tier(tier: str) -> str:
"""An unrecognised tier falls back to always_on — the SAFE direction.
Getting this wrong the other way would silently stop a rule binding, which
is the one failure this whole milestone exists to prevent. A rule that
preloads when it did not need to costs context; a rule that quietly stops
preloading costs the behaviour it was written for.
"""
return tier if tier in TIERS else "always_on"
def last_verified_label(rule: Rule) -> str | None:
"""How long ago the rule's check passed — None when it carries no check.
One helper because two surfaces need the same answer and the brief-dict
lesson in rule_brief's docstring is what happens otherwise: three copies
that had already drifted. `None` means "this rule is a decision, the
question does not apply"; "never" means "it is a fact and nobody has
confirmed it" — a distinction worth keeping, because the second is the
one worth acting on.
"""
if not rule.verify_with:
return None
return rule.verified_at.date().isoformat() if rule.verified_at else "never"
def rule_brief(rule: Rule, **extra) -> dict:
"""The shape a rule takes when it is SURFACED rather than opened.
One builder for every payload that hands rules to an agent, because there
were three copies of this dict and they had already diverged — two carried
`topic_id`, one didn't, and none carried the timestamps the model has held
all along. That omission is why a rule written before the capability it
duplicates was indistinguishable, at read time, from one still doing work
(the FabledCurator case, note 3026).
`updated_at` is a DATE, not a stamp: the question it answers is "how old
is this?", and a full ISO string across an always-on set is ~2k characters
of payload for a precision nobody reads.
`why` and `how_to_apply` are deliberately NOT here — they are the depth a
caller gets from get_rule, and putting them in every listing is the bloat
this milestone is about.
"""
out = {
"id": rule.id,
"title": rule.title,
"statement": rule.statement,
"topic_id": rule.topic_id,
"tier": rule.tier,
"updated_at": rule.updated_at.date().isoformat() if rule.updated_at else None,
}
# Attached only when present (#2483: never a null key that reads as a
# capability the record doesn't have).
if rule.when_to_apply:
out["when_to_apply"] = rule.when_to_apply
if rule.arose_from_id:
out["arose_from_id"] = rule.arose_from_id
# Present ONLY on a rule that carries a check — its presence is the
# signal, and it says two things at once: this rule asserts a fact that
# can go false, and here is how long ago anyone confirmed it. The check
# text itself stays in get_rule; a listing needs to know WHICH rules can
# rot, not how to test them. "never" rather than null, per #2483: a key
# that reads as an unused capability is a different claim from a rule
# nobody has ever verified.
stamp = last_verified_label(rule)
if stamp:
out["last_verified"] = stamp
out.update({k: v for k, v in extra.items() if v is not None})
return out
def _refresh_rule_embedding(rule: Rule) -> None:
"""Re-index a rule after a write. Fire-and-forget, like the note twin.
Lazy import so this module doesn't pull in the embedder; every exception
swallowed because a rule that SAVED must not fail on its index refresh —
a stale vector costs a missed search hit, a raised exception costs the
write. No running loop (unit tests, scripts) is ordinary, not an error.
"""
try:
import asyncio
from scribe.services.embeddings import upsert_rule_embedding
asyncio.create_task(
upsert_rule_embedding(
rule.id, rule.title, rule.statement, rule.when_to_apply,
)
)
except RuntimeError:
pass # no running loop — a sync caller, not a failure
except Exception: # noqa: BLE001 - never let indexing break a write
logger.exception("embedding refresh failed for rule %s", rule.id)
async def co_surfaced_partners(
user_id: int, rule_ids: list[int], exclude_ids: set[int] | None = None,
) -> list[Rule]:
"""Rules that must arrive WITH the given ones, because they fail together.
This is the whole reason `co_surfaces` exists. Rule 144 was split off rule
46 and folded back into it the same day, on the correct observation that
"either rule could surface without the other and miss exposing a project to
what the entire shape is intended to be." Merging was the only fix
available; this is the fix that should have been available.
Two limits, both deliberate:
- Only rules the caller OWNS. An edge is not a back door into someone
else's rulebook.
- `exclude_ids` is honoured, and callers pass the project's SUPPRESSIONS.
A project that explicitly muted a rule should not have it dragged back in
by an edge — the suppression is a decision, and the edge does not
outrank it.
"""
if not rule_ids:
return []
known = set(rule_ids) | (exclude_ids or set())
async with async_session() as session:
edges = (await session.execute(
select(RuleRelation).where(
RuleRelation.kind == "co_surfaces",
or_(
RuleRelation.from_rule_id.in_(rule_ids),
RuleRelation.to_rule_id.in_(rule_ids),
),
)
)).scalars().all()
partners = {
(edge.to_rule_id if edge.from_rule_id in known else edge.from_rule_id)
for edge in edges
} - known
if not partners:
return []
# Ownership re-checked per partner rather than assumed from the edge.
out = []
for partner_id in sorted(partners):
rule = await _fetch_owned_rule(session, partner_id, user_id)
if rule is not None:
out.append(rule)
return out
async def rule_detail(user_id: int, rule: Rule, system_ids: list[int] | None = None) -> dict:
"""The full record, with its areas and edges attached.
ONE seam for both doors and every write path, so create, update and get
cannot disagree about what a rule looks like coming back — the same
reasoning as attach_relations for notes (#2859), and the same reasoning
rule_brief exists for one level down.
`system_ids=None` means "leave the tags alone"; a list (including [])
REPLACES them.
"""
if system_ids is not None:
await set_rule_systems(rule.id, user_id, system_ids)
data = rule.to_dict()
systems = (await list_rule_systems([rule.id])).get(rule.id, [])
relations = (await list_rule_relations([rule.id])).get(rule.id, [])
# Attached only when present (#2483): an empty key reads as a capability
# the record has and isn't using, which is a different claim.
if systems:
data["systems"] = systems
if relations:
data["relations"] = relations
return data
async def create_rule(
topic_id: int, user_id: int, title: str, statement: str,
why: str = "", how_to_apply: str = "", order_index: int = 0,
when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0,
verify_with: str = "", expires_when: str = "",
) -> Rule:
async with async_session() as session:
await _assert_topic_owned(session, topic_id, user_id)
@@ -290,19 +481,27 @@ async def create_rule(
topic_id=topic_id,
title=title,
statement=statement,
when_to_apply=when_to_apply or None,
tier=_valid_tier(tier),
why=why or None,
how_to_apply=how_to_apply or None,
verify_with=verify_with or None,
expires_when=expires_when or None,
arose_from_id=arose_from_id or None,
order_index=order_index,
)
session.add(rule)
await session.commit()
await session.refresh(rule)
_refresh_rule_embedding(rule)
return rule
async def create_project_rule(
project_id: int, user_id: int, title: str, statement: str,
why: str = "", how_to_apply: str = "", order_index: int = 0,
when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0,
verify_with: str = "", expires_when: str = "",
) -> Rule:
"""Create a rule scoped to a single project (no rulebook ceremony).
@@ -316,13 +515,19 @@ async def create_project_rule(
project_id=project_id,
title=title,
statement=statement,
when_to_apply=when_to_apply or None,
tier=_valid_tier(tier),
why=why or None,
how_to_apply=how_to_apply or None,
verify_with=verify_with or None,
expires_when=expires_when or None,
arose_from_id=arose_from_id or None,
order_index=order_index,
)
session.add(rule)
await session.commit()
await session.refresh(rule)
_refresh_rule_embedding(rule)
return rule
@@ -394,15 +599,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)
@@ -412,11 +659,25 @@ async def list_always_on_rules(user_id: int, limit: int = 100) -> list[Rule]:
Rule.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
Rulebook.deleted_at.is_(None),
# TIER (milestone 307). This is the SESSION-START call, made
# before any project is in scope — there is no area vocabulary
# to match a conditional rule against yet, so only the
# unconditional tier belongs here. A conditional rule reaches a
# session through enter_project (by area) or search (by
# meaning), not by being resident.
#
# Behaviour is unchanged until rules are actually re-tiered:
# `tier` defaults to always_on, so every existing rule still
# arrives exactly as it did.
Rule.tier == "always_on",
)
.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())
@@ -463,20 +724,218 @@ async def get_rule(rule_id: int, user_id: int) -> Optional[Rule]:
return await _fetch_owned_rule(session, rule_id, user_id)
async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]:
async def update_rule(
rule_id: int, user_id: int, clear: Iterable[str] = (), **fields,
) -> Optional[Rule]:
"""Patch a rule. `clear` names fields to unset; **fields carries new values.
Clearing is EXPLICIT and separate because a nullable field cannot be
emptied by passing it. The MCP door reads "" as "leave this alone" — an
agent filling three fields must not wipe the other five — so a caller
there has no value that means "remove it", and a rule that stops being a
constraint genuinely needs its check removed. Naming the field is the one
form that cannot happen by accident.
Callers that DO have a meaningful empty value (the REST door, where a
cleared form input arrives as "") get the same outcome through
NULLABLE_RULE_TEXT normalisation below, so the two doors keep their own
idiom and agree about the result.
"""
async with async_session() as session:
rule = await _fetch_owned_rule(session, rule_id, user_id)
if rule is None:
return None
allowed = {"title", "statement", "why", "how_to_apply", "order_index"}
allowed = {
"title", "statement", "why", "how_to_apply", "order_index",
"when_to_apply", "tier", "arose_from_id",
"verify_with", "expires_when",
}
check_before = rule.verify_with
for key in clear:
if key in allowed and key in NULLABLE_RULE_TEXT:
setattr(rule, key, None)
elif key == "arose_from_id":
setattr(rule, key, None)
for key, value in fields.items():
if key in allowed and value is not None:
setattr(rule, key, value)
if key not in allowed or value is None:
continue
if key == "tier":
value = _valid_tier(value)
elif key in NULLABLE_RULE_TEXT:
value = value or None
elif key == "arose_from_id":
value = value or None
setattr(rule, key, value)
# A verification stamp certifies A CHECK, not a rule. Rewrite or
# remove the check and the old stamp certifies something that no
# longer exists — so it is dropped, and the rule re-enters the sweep.
# The safe direction, for the same reason _valid_tier falls back to
# always_on: a rule wrongly listed as due costs one look, a rule
# wrongly vouched for costs the thing the sweep exists to catch.
if rule.verify_with != check_before:
rule.verified_at = None
await session.commit()
await session.refresh(rule)
_refresh_rule_embedding(rule)
return rule
# ── Canon tags + typed edges (milestone 307) ───────────────────────────
async def set_rule_systems(
rule_id: int, user_id: int, canonical_ids: list[int],
) -> list[int] | None:
"""Replace which global AREAS a rule is about. None if not owned.
Set-semantics like set_record_systems: the list given IS the state after,
so an empty list clears the tags. Points at the canonical catalog, never a
project's System — a family rule tagged to one project's row would bind
itself to that project's vocabulary.
"""
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.rulebook import rule_systems as rule_systems_t
async with async_session() as session:
rule = await _fetch_owned_rule(session, rule_id, user_id)
if rule is None:
return None
wanted = set(canonical_ids or [])
if wanted:
live = set((await session.execute(
select(CanonicalSystem.id).where(
CanonicalSystem.id.in_(wanted),
CanonicalSystem.deleted_at.is_(None),
)
)).scalars().all())
# Silently dropping an unknown id would leave the caller believing
# a tag exists; keep only the live ones and report what stuck.
wanted &= live
await session.execute(
sql_delete(rule_systems_t).where(rule_systems_t.c.rule_id == rule_id)
)
for canonical_id in sorted(wanted):
await session.execute(
insert(rule_systems_t).values(rule_id=rule_id, canonical_id=canonical_id)
)
await session.commit()
return sorted(wanted)
async def list_rule_systems(rule_ids: list[int]) -> dict[int, list[dict]]:
"""The canon tags for a batch of rules, keyed by rule id.
Batched on purpose: the surfacing paths ask about a whole payload of rules
at once, and one query per rule would turn every session start into an
N+1.
"""
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.rulebook import rule_systems as rule_systems_t
if not rule_ids:
return {}
async with async_session() as session:
rows = (await session.execute(
select(rule_systems_t.c.rule_id, CanonicalSystem.id, CanonicalSystem.name)
.join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id)
.where(
rule_systems_t.c.rule_id.in_(rule_ids),
CanonicalSystem.deleted_at.is_(None),
)
.order_by(CanonicalSystem.order_index)
)).all()
out: dict[int, list[dict]] = {}
for rule_id, canonical_id, name in rows:
out.setdefault(rule_id, []).append({"id": canonical_id, "name": name})
return out
async def add_rule_relation(
user_id: int, from_rule_id: int, to_rule_id: int, kind: str, note: str = "",
) -> RuleRelation | None:
"""Draw a typed edge between two rules. None if either isn't owned.
Both ends are ownership-checked: an edge is only meaningful if the drawer
can see both rules, and a one-sided edge would surface a rule the caller
has no business reading.
Idempotent — re-drawing an existing edge returns it rather than raising, so
a true-up pass can be re-run without cleaning up first.
"""
if kind not in RELATION_KINDS:
raise ValueError(f"kind must be one of {RELATION_KINDS}, got {kind!r}")
if from_rule_id == to_rule_id:
raise ValueError("a rule cannot relate to itself")
async with async_session() as session:
for rid in (from_rule_id, to_rule_id):
if await _fetch_owned_rule(session, rid, user_id) is None:
return None
existing = await session.scalar(
select(RuleRelation).where(
RuleRelation.from_rule_id == from_rule_id,
RuleRelation.to_rule_id == to_rule_id,
RuleRelation.kind == kind,
)
)
if existing is not None:
return existing
relation = RuleRelation(
from_rule_id=from_rule_id, to_rule_id=to_rule_id,
kind=kind, note=note or None,
)
session.add(relation)
await session.commit()
await session.refresh(relation)
return relation
async def remove_rule_relation(user_id: int, relation_id: int) -> bool:
async with async_session() as session:
relation = await session.get(RuleRelation, relation_id)
if relation is None:
return False
if await _fetch_owned_rule(session, relation.from_rule_id, user_id) is None:
return False
await session.delete(relation)
await session.commit()
return True
async def list_rule_relations(rule_ids: list[int]) -> dict[int, list[dict]]:
"""Edges touching a batch of rules, keyed by rule id.
`co_surfaces` is reported from BOTH ends off a single stored row — it means
"these fail together", which is not a claim with a direction. The other two
are directional and are reported as stored, with `direction` naming which
end this rule is: an override read from the wrong end would invert what it
says.
"""
if not rule_ids:
return {}
async with async_session() as session:
rows = (await session.execute(
select(RuleRelation).where(
(RuleRelation.from_rule_id.in_(rule_ids))
| (RuleRelation.to_rule_id.in_(rule_ids))
)
)).scalars().all()
out: dict[int, list[dict]] = {}
wanted = set(rule_ids)
for relation in rows:
if relation.from_rule_id in wanted:
out.setdefault(relation.from_rule_id, []).append({
"id": relation.id, "kind": relation.kind,
"rule_id": relation.to_rule_id,
"direction": "outgoing", "note": relation.note or "",
})
if relation.to_rule_id in wanted:
out.setdefault(relation.to_rule_id, []).append({
"id": relation.id, "kind": relation.kind,
"rule_id": relation.from_rule_id,
"direction": "incoming", "note": relation.note or "",
})
return out
async def delete_rule(rule_id: int, user_id: int) -> None:
async with async_session() as session:
rule = await _fetch_owned_rule(session, rule_id, user_id)
@@ -488,7 +947,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 +1027,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:
@@ -711,10 +1215,13 @@ async def get_applicable_rules(
# Applicable rules (limit + 1 so we can detect truncation). Filter
# in SQL so truncation reflects the post-suppression count, not the
# raw subscription count.
# Selects the ENTITY, not a column list: rule_brief is the one place
# that decides which fields a surfaced rule carries, and a column list
# here would be a second such decision to keep in step. The row count
# is bounded by `limit`, so this is a listing, not a scan.
rules_q = (
select(
Rule.id, Rule.title, Rule.statement,
RulebookTopic.id.label("topic_id"),
Rule,
RulebookTopic.title.label("topic_title"),
Rulebook.id.label("rulebook_id"),
Rulebook.title.label("rulebook_title"),
@@ -731,6 +1238,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,
@@ -741,21 +1251,41 @@ async def get_applicable_rules(
rules_q = rules_q.where(Rule.id.notin_(suppressed_rule_ids))
if suppressed_topic_ids:
rules_q = rules_q.where(Rule.topic_id.notin_(suppressed_topic_ids))
# TIER (milestone 307). always_on rules are resident, as every rule was
# before tiers existed. A conditional rule is REACHABLE, and reaches
# this project only when it is tagged to an area this project actually
# works in — a deterministic tag match, never a similarity score, so
# bindingness never depends on a ranking (D7).
#
# Applied in SQL rather than by filtering afterwards, so `limit` counts
# the rules that will actually be surfaced instead of counting rules
# that are about to be dropped.
project_area_ids = (await session.execute(
select(System.canonical_id).where(
System.project_id == project_id,
System.canonical_id.is_not(None),
System.deleted_at.is_(None),
System.status == "active",
).distinct()
)).scalars().all()
reachable = select(rule_systems.c.rule_id).where(
rule_systems.c.canonical_id.in_(project_area_ids)
) if project_area_ids else None
tier_clause = (Rule.tier == "always_on")
if reachable is not None:
tier_clause = or_(tier_clause, Rule.id.in_(reachable))
rules_q = rules_q.where(tier_clause)
rule_rows = (await session.execute(rules_q)).all()
truncated = len(rule_rows) > limit
rules = [
{
"id": rid, "title": rtitle, "statement": stmt,
"topic_id": ti, "topic_title": tt,
"rulebook_id": rbi, "rulebook_title": rbt,
}
for rid, rtitle, stmt, ti, tt, rbi, rbt in rule_rows[:limit]
rule_brief(rule, topic_title=tt, rulebook_id=rbi, rulebook_title=rbt)
for rule, tt, rbi, rbt in rule_rows[:limit]
]
# Project-scoped rules — verifies ownership via Project.user_id.
from scribe.models.project import Project
proj_rules_q = (
select(Rule.id, Rule.title, Rule.statement)
select(Rule)
.join(Project, Rule.project_id == Project.id)
.where(
Project.user_id == user_id,
@@ -765,11 +1295,39 @@ async def get_applicable_rules(
)
.order_by(Rule.order_index, Rule.title)
)
if reachable is not None:
proj_rules_q = proj_rules_q.where(
or_(Rule.tier == "always_on", Rule.id.in_(reachable))
)
else:
proj_rules_q = proj_rules_q.where(Rule.tier == "always_on")
proj_rule_rows = (await session.execute(proj_rules_q)).all()
project_rules = [
{"id": rid, "title": rtitle, "statement": stmt}
for rid, rtitle, stmt in proj_rule_rows
]
project_rules = [rule_brief(rule) for (rule,) in proj_rule_rows]
# Edges travel with the rules they belong to (milestone 307).
#
# A co_surfaces partner that was not otherwise selected is ADDED, because a
# rule that arrives without the half it fails with is the failure the edge
# was created to prevent. Suppressions are passed as exclusions so an
# explicit mute still wins over an edge.
surfaced_ids = [r["id"] for r in rules] + [r["id"] for r in project_rules]
partners = await co_surfaced_partners(
user_id, surfaced_ids, exclude_ids=set(suppressed_rule_ids),
)
for partner in partners:
rules.append(rule_brief(partner, via="co_surfaces"))
surfaced_ids.append(partner.id)
# Relations on every surfaced rule, so a reader can see that an override
# exists rather than discovering the contradiction by acting on the wrong
# one. Areas too — they are why a conditional rule is here at all.
edges = await list_rule_relations(surfaced_ids)
areas = await list_rule_systems(surfaced_ids)
for brief in (*rules, *project_rules):
if edges.get(brief["id"]):
brief["relations"] = edges[brief["id"]]
if areas.get(brief["id"]):
brief["systems"] = areas[brief["id"]]
return {
"rules": rules,
@@ -778,6 +1336,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 +1345,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 +1359,152 @@ 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", []),
}
# ── The staleness sweep (milestone 312) ────────────────────────────────
async def rules_due_for_verification(
user_id: int,
older_than_days: int = 0,
tier: str = "",
never_only: bool = False,
) -> list[Rule]:
"""Rules that carry a check, oldest verification first, never-checked top.
THE QUERY THIS MILESTONE EXISTS FOR. `verify_with` and `expires_when` are
storage; this is what turns them into something that gets acted on. The
307 audit cost a session and found four broken rules by luck — this makes
the same question a list, and staleness measurable by age instead of
discoverable by accident.
Ordered `verified_at` ASC NULLS FIRST: never-checked outranks
checked-long-ago, because a rule nobody has ever confirmed is a claim
with no evidence behind it at all.
Rules with no `verify_with` never appear. That is not an omission — they
are decisions, there is nothing to go and check, and listing them would
dilute the result until nobody reads it.
Ownership-scoped exactly like list_rules: a rule reached through an owned
rulebook, or scoped to an owned project. Rules have no sharing ACL in this
schema — no rule_shares, no rulebook_shares — so there is no wider set to
consult here, unlike notes and projects.
Args:
user_id: whose rules.
older_than_days: only rules last verified longer ago than this.
Never-checked rules always qualify — they are the most overdue
thing there is. 0 = no age filter.
tier: "always_on" or "conditional" to narrow. Raises on anything else
rather than falling back: _valid_tier's silent always_on default
is right for a WRITE (the safe direction is to keep binding), and
wrong for a FILTER, where it would quietly answer a different
question than the one asked.
never_only: only rules that have never been verified.
"""
from datetime import datetime, timedelta, timezone
from scribe.models.project import Project
if tier and tier not in TIERS:
raise ValueError(f"tier must be one of {TIERS}, got {tier!r}")
async with async_session() as session:
stmt = (
select(Rule)
.outerjoin(RulebookTopic, Rule.topic_id == RulebookTopic.id)
.outerjoin(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
.outerjoin(Project, Rule.project_id == Project.id)
.where(
Rule.deleted_at.is_(None),
Rule.verify_with.is_not(None),
# One statement rather than two queries merged in Python, so
# the ordering below is the database's and cannot disagree
# with itself across the two halves of the XOR.
or_(
and_(
Rulebook.owner_user_id == user_id,
Rulebook.deleted_at.is_(None),
RulebookTopic.deleted_at.is_(None),
),
Project.user_id == user_id,
),
)
)
if tier:
stmt = stmt.where(Rule.tier == tier)
if never_only:
stmt = stmt.where(Rule.verified_at.is_(None))
elif older_than_days > 0:
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
stmt = stmt.where(
or_(Rule.verified_at.is_(None), Rule.verified_at < cutoff)
)
stmt = stmt.order_by(Rule.verified_at.asc().nullsfirst(), Rule.id)
return list((await session.execute(stmt)).scalars().all())
def verification_row(rule: Rule) -> dict:
"""One row of the sweep — the CHECK in full, unlike rule_brief.
The opposite call from a listing: here the caller is about to go and run
the check, so the text they need is the point of the payload rather than
the bloat. `days_since` is computed rather than left to the reader,
because "2026-06-14" and "74 days" prompt different reactions and only
one of them is the question being asked.
"""
from datetime import datetime, timezone
days = None
if rule.verified_at is not None:
stamp = rule.verified_at
if stamp.tzinfo is None:
stamp = stamp.replace(tzinfo=timezone.utc)
days = (datetime.now(timezone.utc) - stamp).days
return {
"id": rule.id,
"title": rule.title,
"statement": rule.statement,
"tier": rule.tier,
"topic_id": rule.topic_id,
"project_id": rule.project_id,
"when_to_apply": rule.when_to_apply or "",
"verify_with": rule.verify_with or "",
"expires_when": rule.expires_when or "",
"last_verified": last_verified_label(rule),
"days_since_verified": days,
}
async def mark_rule_verified(
rule_id: int, user_id: int, still_true: bool = True,
) -> Optional[Rule]:
"""Stamp a rule as verified — or, when the check FAILED, refuse to.
A failing check is the outcome worth having, and the asymmetry is
deliberate: passing writes a stamp, failing writes nothing. There is no
"verified false" state to record, because a rule whose check failed is
not a rule in a special condition — it is a rule that is WRONG, and the
only honest resolutions are to correct it, retire it, or find out why.
Recording the failure as a flag would let it sit there being false with
the sweep quietly satisfied that someone had looked.
So a failed check leaves `verified_at` untouched, and the rule stays at
the top of the sweep until someone actually deals with it.
Returns None when the rule is not found, not owned, or carries no
`verify_with` — nothing to verify is a different answer from verified.
"""
from datetime import datetime, timezone
async with async_session() as session:
rule = await _fetch_owned_rule(session, rule_id, user_id)
if rule is None or not rule.verify_with:
return None
if still_true:
rule.verified_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(rule)
return rule
+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"

Some files were not shown because too many files have changed in this diff Show More