Compare commits

...
53 Commits
Author SHA1 Message Date
bvandeusen f0c915a6bc Merge pull request 'Ledger follow-ups from the shape audit (milestone 294: #2868–#2874)' (#122) from dev into main
CI & Build / Build & push image (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 57s
CI & Build / integration (push) Successful in 21s
2026-08-21 15:24:03 -04:00
bvandeusenandClaude Fable 5 6a0f8ad328 feat(backup): code_shape_uses travels (v9); consumer-map test expects uses (#2870)
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 25s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:21:13 -04:00
bvandeusenandClaude Fable 5 d4c7b0e48d feat(ledger): uses edges — consumption is its own relation, conformance keeps one snippet_id (#2870, milestone 294)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Failing after 29s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Failing after 39s
CI & Build / Build & push image (push) Skipped
A shape can follow one convention canon AND call several helper canons; the
row's single snippet_id made the 2026-08 audit pick (hash_token won, the
service-function convention lost), and hook evidence — pulled a snippet, then
wrote code naming it — was stamped as instance when it is a uses fact.

- code_shape_uses (migration 0084): shape → snippet, basis, evidence; unique
  per pair; cascades with both ends. USE_BASES: reference | hook | agent |
  audit | import. A judgment-grade basis overwrites a mechanical one, never
  the reverse.
- classify_shapes items and classify_shapes_by_rule take uses=[snippet ids]
  (targets validated like snippet_id; all-or-nothing).
- The write-path hook writes a uses edge for every pulled canon the payload
  names (the instance stamp is unchanged); the proposer writes a uses edge
  for every canon a body names (reference_canons: kind + language family +
  stoplist, same rules as the reference basis) — the mechanical form of
  "auto-confirm own-import references" deferred from #2871.
- list_shapes(uses=N) lists the consumers of a canon; get_snippet's consumer
  map gains `uses` beside instances/variants.

Operator decision on #2870 (2026-08-21): keep one snippet_id, add uses edges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:18:25 -04:00
bvandeusenandClaude Fable 5 bfe5a461b4 test(ledger): derive group sizes follow the #2872 order
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 21s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:15:32 -04:00
bvandeusenandClaude Fable 5 3849c6fff3 test(ledger): derive label order follows #2872; binding-ref test imports compute_coverage
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Canceled after 22s
CI & Build / Python tests (push) Canceled after 22s
CI & Build / integration (push) Canceled after 21s
CI & Build / Build & push image (push) Canceled after 0s
CI & Build / Python lint (push) Successful in 4s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:15:09 -04:00
bvandeusenandClaude Fable 5 1a8e5787e8 feat(ledger): reason codes, case-insensitive repo filter, next action on the coverage line (#2874, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Canceled after 32s
CI & Build / Python tests (push) Canceled after 31s
CI & Build / Build & push image (push) Canceled after 0s
- code_shapes.reason_code (migration 0083): an optional code from a fixed
  catalogue (scoped-css, one-off-handler, test-helper, convention-plumbing,
  pure-helper, generated, script, typed-record) beside the prose reason, so
  the ledger can be filtered/aggregated by kind of one-off; validated in
  classify_shapes and classify_shapes_by_rule; on to_dict/to_compact.
- Snippet location lookups match repo case-insensitively in both dialects
  (location_matches / location_jsonpath via like_regex flag "i") — "Scribe"
  vs "FabledScribe" vs "fabledscribe" recorded free-form hid half the canon
  from list_snippets(repo=, path=).
- coverage line names the next action: "top canon #N ×k" (biggest proposal
  queue) and "top copy <label> ×files" (widest body-identical group).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:14:34 -04:00
bvandeusenandClaude Fable 5 d01201539b test(ledger): derive summary expectation follows #2872 — dup group before name group
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 6s
CI & Build / Python tests (push) Successful in 58s
CI & Build / integration (push) Failing after 20s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Build & push image (push) Successful in 22s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:11:25 -04:00
bvandeusenandClaude Fable 5 1209e1c2d9 feat(ledger): a repo binding names the branch its ledger follows — bind_repo(ref=) (#2873, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Canceled after 30s
CI & Build / Python tests (push) Canceled after 30s
CI & Build / Build & push image (push) Canceled after 0s
CI & Build / integration (push) Failing after 25s
Project 2 is bound to main, so every consolidation of the 2026-08 audit was
invisible to the ledger until the dev→main merge; the operator works on dev
(rule 1). repo_bindings.ref (migration 0082, nullable) is the branch the
coverage refresh reads; NULL keeps the forge default branch. set_binding takes
ref (name sets, "" clears, None leaves standing); bindings_for_project feeds
the refresh; bind_repo exposes ref ("-" clears). to_dict carries it.

Operator decision on #2873 (2026-08-21): per-binding ref, chosen at bind time,
default the repo default branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:10:54 -04:00
bvandeusenandClaude Fable 5 57d68c9355 feat(ledger): derive readout ranks body-identical groups first; CSS fingerprints are declarations, not selectors (#2872, milestone 294)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 32s
The 2026-08 audit consolidated identical bodies under different names and
files (five auth views' CSS, two Workspace formatDate()s, four modal blocks)
while the derive readout led with name groups (to_dict ×25, main ×5, load ×6)
that were convention or coincidence.

- proposal_summary ranks dup:<sha> groups above name groups, wider file spread
  first, and carries `files`; scoped rows (#2869) are in the readout, since
  that is where view-level copies live.
- extract_definitions fingerprints a CSS rule by its declarations — the row
  identity already carries the selector — so .closed-msg / .error-block /
  .success-msg with one body are one dup group. One-time effect: judged css
  rows whose stored fingerprint predates this may show a recheck on the next
  refresh (the judgment stands; re-confirm).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:08:57 -04:00
bvandeusenandClaude Fable 5 1126bbe84f test(ledger): the divergence acceptance case uses TS canon — proposer v3 gates sym bases by language family (#2871)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 22s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Canceled after 4s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:07:48 -04:00
bvandeusenandClaude Fable 5 1ab614bfbe feat(ledger): the scoped bucket — by-construction one-offs are stamped by the sync, not judged by a person (#2869, milestone 294)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / integration (push) Failing after 25s
CI & Build / Python tests (push) Canceled after 51s
CI & Build / Build & push image (push) Canceled after 0s
The 2026-08 audit left 77% of Scribe's ledger `exempt`, most of it a Vue
component's scoped <style> rules and <script setup> functions — one-offs by
construction (unreachable from any other file) that add nothing when judged
one by one and bury the rows a person should look at.

- coverage: Definition carries its line; scoped_definitions() names, per
  .vue file, every sym and every css rule inside <style scoped>; ArchiveShape
  carries the flag.
- sync: such rows are stamped status=scoped / classified_by=mechanical with
  the by-construction reason (history event recorded); un-stamped back to
  unclassified if a later tree makes them ordinary; a judgment overrides.
- The machine still sees them: proposer, derive grouping, divergence, hook
  evidence, canonical stamping and classify_shapes_by_rule's default all
  treat unclassified + scoped as the unjudged set (_MECHANICAL_TODO). Only
  the human todo (status=unclassified) and largest_gaps exclude them.
- accounting counts `scoped`; coverage line and the project card legend show
  it; SHAPE_STATUSES gains it (no DB CHECK on status — no migration).
- shape-accounting skill documents the bucket; plugin 0.1.37.

Operator decision on #2869 (2026-08-21): keep extracting everything, stamp
mechanically, keep `exempt` a human judgment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:06:52 -04:00
bvandeusenandClaude Fable 5 9abc4443fb feat(ledger): audit surfaces — list_shapes(compact=True) and classify_shapes_by_rule, the sweep form of a judgment (#2868, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m0s
CI & Build / Build & push image (push) Successful in 26s
The 2026-08 audit judged 3,427 rows in 14 hand-driven batches through a raw
MCP client because a list_shapes page overflowed the tool budget and every
row had to be sent back one by one. Now:

- list_shapes(compact=True): path · symbol · kind · status · signature, plus
  snippet_id / by / proposal / diverges_from / recheck only when set. A full
  500-row page fits. CodeShape.to_compact() is the row shape.
- classify_shapes_by_rule(project_id, path, status, pattern=, kind=,
  snippet_id=, reason=, via=, include_judged=): ONE judgment over every
  unclassified live row under a directory whose symbol matches a glob;
  judged rows are untouched unless include_judged; canonical is refused;
  same gates as the row form; one transaction; returns count + sample.
  shape_ledger.classify_shapes_where / rule_matches (pure) carry it.

Tests: compact row pinned, rule_matches directory/glob/kind semantics, the
tool mount, and an integration sweep (unclassified-only, include_judged,
gates).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:02:55 -04:00
bvandeusenandClaude Fable 5 3a4031d7f8 fix(ledger): proposer v3 — sym bases gated by language family, reference skips generic verbs, semantic held to the shape's own project (#2871, milestone 294)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Failing after 25s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 26s
The 2026-08 audit examined 407 proposals. Every cross-language hit was wrong:
the Python MCP tool-module canon (symbol `register`) was offered for each auth
view's handleSubmit (it calls authStore.register()) and for the TS auth store's
own `register`; Minstrel/Forge TS canon matched Python bodies by resemblance.
Every cross-project semantic proposal was noise.

- Canon carries the snippet's language; match_canon skips a sym canon whose
  family (py / js / css / sh / sql, by language ↔ by path extension) differs
  from the shape's. Unknown on either side = no gate.
- The reference basis ignores a stoplist of generic verbs (register, load,
  save, get, …): a bare mention is not a call site of THIS canon; the symbol
  basis still catches a second definition, and the call-site relation moves
  to `uses` edges with #2870.
- The semantic arm only reaches canon in the shape's own project and family;
  symbol/text still reach family canon elsewhere (note 2786).
- _PROPOSER_VERSION 2 → 3 so standing proposals re-examine on the next refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 15:00:48 -04:00
bvandeusen 141246ac2c Merge pull request 'fix(frontend): drop the three .modal-overlay copies left after the modal canon move' (#121) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / integration (push) Successful in 25s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 39s
2026-08-21 12:58:25 -04:00
bvandeusenandClaude Fable 5 520381e22b fix(frontend): drop the three .modal-overlay copies the comment-preceded rule regex skipped — components.css owns it (#2831)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / integration (push) Successful in 24s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 38s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:52:29 -04:00
bvandeusen 58c074a324 Merge pull request 'Shape audit (milestone 296): tests helpers, alembic, models, MCP, routes, services, frontend consolidation' (#120) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 40s
2026-08-21 12:48:08 -04:00
bvandeusenandClaude Fable 5 2a6c55dacb refactor(frontend): auth-shared.css, apiErrorMessage, one date helper per shape, modal canon in components.css — the frontend pass of the shape audit (#2831 #2832, milestone 296)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 16s
- assets/auth-shared.css: the five auth views carried byte-identical scoped
  copies of the page/card/brand/footer/field/input/error rules (~60 lines
  each); they now load one stylesheet the way the editors load
  editor-shared.css. .closed-msg/.error-block/.success-msg (identical bodies)
  are one .auth-note; the form rules are scoped under .auth-card so nothing
  leaks into the rest of the app.
- api/client.apiErrorMessage(e, fallback): the one place the {"error"} envelope
  is unpacked; replaces ten six-line `"body" in e` catch blocks.
- utils/dateFormat: fmtDate / fmtStamp / fmtLogStamp replace eight local
  formatDate/formatTime copies (three byte-identical pairs); the file’s old
  Calendar/Home helpers had no callers and are gone. useRelativeTime gains
  relativeTimeOrDate for the two workspace panels’ identical variant.
- components.css now owns the .modal-* shape (overlay/card/title/message/
  actions/btn/primary/danger). It was copied into four views and lived in
  editor-shared.css, which ConfirmDialog — styleless, teleported to <body> —
  silently depended on: opened from SnippetDetailView before any editor view
  had loaded, it rendered unstyled. Views keep only their own overrides.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:41:18 -04:00
bvandeusenandClaude Fable 5 7d48eb0b1b refactor(services): one periodic-task shape, one APScheduler job shape, one token hash, one summary rule — the services pass of the shape audit (#2830, milestone 296)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 24s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 24s
- background.start_periodic(interval, work, label=) replaces the three hand-rolled
  while-True/sleep/try loops in logging, auth and notifications.
- services/scheduler.ScheduledJob replaces the four private BackgroundScheduler
  copies in recurrence/version_pinning/trash/db_maintenance schedulers; public
  start_/stop_/reschedule_ surfaces unchanged.
- api_keys.hash_token is the one sha256 helper; auth.py used to inline it 5x.
- auth.is_registration_open reads via settings.get_admin_setting; notification
  prefs read via settings.get_setting; _fire_share_email uses _get_user_email.
- projects.get_project_summary / milestones.get_project_milestone_summary are
  now the one-id view of their batch siblings instead of a second copy of the
  queries; sharing.best_permission_by (was _deduplicate_by_permission) is the
  one rank-dedup, now also used by list_projects_for_user.
- backup: the row builders for every section both exporters carry are named
  functions, so a column added to one export cannot silently miss the other.
- iso() from models.base replaces the attr.isoformat()-if-attr-else-None idiom
  and db_maintenance._iso across services; backup keeps its explicit shape.
- trash.py hoists the sql_delete/timedelta imports it re-imported per function.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:34:28 -04:00
bvandeusenandClaude Fable 5 92e38ff17b test(routes): the prior-art contract guards read the handler plus its _project_scope helper (area 5 follow-up)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 6s
CI & Build / integration (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 28s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:26:01 -04:00
bvandeusenandClaude Fable 5 64c641ce80 refactor(routes): one supersession seam for REST and MCP; PUT/PATCH notes share a handler; shared mask/not-found/caller helpers (#2829, milestone 296 area 5)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Reading the 28 route modules against each other and against the MCP tools:
- routes/notes.py carried a PUT and a PATCH handler that were the same
  function minus the supersedes contract on one of them — one handler now
  serves both verbs, so both carry it.
- The two _attach_supersession copies (REST + MCP) become
  supersession_svc.attach_relations(uid, note_id, data, hint=) — the seam
  the two surfaces must agree through; only the agent surface adds the
  one-sentence reading hint.
- Three local _uid() wrappers over g.user.id → scribe.auth.get_current_user_id
  like every other module; design_systems' private _not_found → routes.utils.
  not_found; the four "********" literals → settings_svc.SECRET_MASK with the
  read/write contract written once.
- routes/plugin.py: the project_id/repo resolution block and the
  comma-separated id parse were copied into three endpoints — _project_scope()
  and _int_list() now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:23:47 -04:00
bvandeusenandClaude Fable 5 c211e12b61 refactor(mcp): one rules_payload() for every surface that hands rules to an agent; drop the dead bearer resolver (#2828, milestone 296 area 4)
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 26s
Reading the 16 tool modules against each other: the six-key applicable-rules
block (applicable_rules, applicable_rules_truncated, subscribed_rulebooks,
project_rules, suppressed_rules, suppressed_topics) was hand-built in five
places — enter_project, get_project, get_task (legacy plans), get_milestone
(three of the six) and services/planning.start_planning. rulebooks_svc.
rules_payload() is now the one place that names them; get_milestone gains the
three it lacked, so every rules-carrying payload reads the same. list_rules /
list_always_on_rules share _rule_summary. mcp/auth.resolve_bearer_to_user_id
duplicated resolve_bearer's parsing and had no product caller (only its own
tests) — removed; the tests now exercise resolve_bearer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:20:36 -04:00
bvandeusenandClaude Fable 5 b0eda32575 refactor(models): one iso() for every to_dict timestamp; mixins replace hand-rolled created_at/updated_at (#2827, milestone 296 area 3)
CI & Build / Python lint (push) Successful in 4s
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 57s
CI & Build / Build & push image (push) Successful in 26s
Reading all 28 models against each other: 54 `x.isoformat() if x else None` /
`x.isoformat()` expressions in 23 to_dict methods, in two guarded/unguarded
wordings, become iso() from models/base.py — uniform, and a row read before
flush serialises as null instead of raising. Rulebook / RulebookTopic / Rule
carried byte-identical copies of TimestampMixin's two columns;
InvitationToken / PasswordResetToken / NoteUsageEvent carried CreatedAtMixin's —
all six now use the mixin. AppLog and RetrievalLog keep their explicit
created_at, commented: their composite index orders on `created_at.desc()`,
which needs the column object in the class body. Schema-neutral (same column
definitions) — no migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:17:42 -04:00
bvandeusenandClaude Fable 5 848ce1592e fix(tests): import make_mock_session in test_version_pinning_prune (batch-2 follow-up)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 18s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:14:44 -04:00
bvandeusenandClaude Fable 5 77bb3729a3 refactor(tests): per-model fakes, FakeMCP and session mocks come from tests/helpers (#2825, milestone 296 area 1, batch 2)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Failing after 36s
CI & Build / Build & push image (push) Skipped
Second pass over the tests/ ledger after bbee0d0. fake_record(**attrs) is the
one MagicMock-with-real-attributes builder (to_dict mirrors them; the
note-2109 hazard documented once); fake_note/fake_task/fake_snippet/
fake_project/fake_milestone/fake_system/fake_rulebook/fake_topic/fake_rule
carry each model's ordinary defaults on top of it, replacing 14 per-file
factories (two rulebook trios in tool-vs-service wordings, _fake_task, _fake_ms,
_fake_project, _plan_note, _fake_snippet, two _snippet adapters now one-liners
over fake_snippet). FakeMCP replaces the five closure-over-a-list registrar
fakes (+ _Recorder); loc() and design_token_stub() replace the paired _loc /
_token / _T stand-ins; every hand-built async_session mock (9 helper defs and
14 inline copies) now starts from make_mock_session(). Call sites rewritten by
AST with each file's former defaults made explicit, so behaviour is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:13:17 -04:00
bvandeusen ac6f248bf9 Merge pull request 'Shape audit area 1 — tests/: one definition each for the copied fixtures and fakes (#2825)' (#119) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 19s
2026-08-21 11:07:11 -04:00
bvandeusenandClaude Fable 5 bbee0d0db1 refactor(tests): one definition each for the copied fixtures and fakes (#2825, milestone 296 area 1)
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 / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 18s
The shape ledger showed the same test scaffolding defined over and over:
_bind_user x12 (byte-identical), _dispose_engine x10 in three wordings,
_no_supersession x3, _make_mock_session x7 in three subsets, a get-or-create
User helper x2 (+3 inlined), and fifteen hand-rolled MagicMock note factories
each re-explaining the same "an auto-MagicMock attribute is truthy" hazard
(note 2109).

Now: conftest.py carries _bind_user / _dispose_engine / _no_supersession as
opt-in fixtures (pytestmark = usefixtures(...) per module, so unit tests pay
nothing), and tests/helpers.py carries make_mock_session(), ensure_user() and
fake_note(**attrs) — the hazard documented once, real values on every
attribute the product reads. Call sites were rewritten by AST so titles with
dashes and commas survived; the three SimpleNamespace _note stand-ins that
only feed a single function stay local.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 11:03:48 -04:00
bvandeusen ac1c2625af Merge pull request 'fix(ledger): proposer precision — own-definition signatures, semantic floor 0.8, ruleset version' (#118) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 35s
CI & Build / TypeScript typecheck (push) Successful in 48s
CI & Build / Python tests (push) Successful in 1m8s
CI & Build / Build & push image (push) Successful in 18s
2026-08-21 08:27:27 -04:00
bvandeusenandClaude Fable 5 dad56a51bd fix(ledger): the proposer matches signatures against a canon's own definition only and floors semantic at 0.8; ruleset version re-examines standing proposals
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 31s
First live run: a canon whose recorded code opens with a call-site example
(confirmed()'s onTrash) made every `async function x(): Promise<void>`
resemble it at 0.86, and the write-path semantic floor paired alembic
upgrade()/downgrade() bodies with unrelated canons at 0.68-0.75. Rows now
remember (body, ruleset) so a tightened rule looks again once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 07:42:12 -04:00
bvandeusen 79f0283608 Merge pull request 'Shape ledger steps 5–7: write-path stamping, mechanical proposer, divergence readout (#2791, #2792, #2793)' (#117) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 24s
CI & Build / integration (push) Successful in 27s
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Build & push image (push) Successful in 19s
2026-08-21 07:32:23 -04:00
bvandeusenandClaude Fable 5 efc650390e fix(plugin-context): the empty write-path payload carries the divergence key too
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / Python tests (push) Successful in 1m3s
CI & Build / Build & push image (push) Successful in 42s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 23:19:00 -04:00
bvandeusenandClaude Fable 5 d74a244b3a feat(ledger): divergence readout — button B where button A is canon, shape history, and judged-shape recheck (#2793, milestone 294 step 7)
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 28s
CI & Build / Python tests (push) Failing after 37s
CI & Build / Build & push image (push) Skipped
Every judgment now goes through one helper that remembers the fingerprint
judged (classified_sha) and writes a code_shape_events row; the sync writes
vanished / reappeared / drifted events and flags recheck_at when a body
moves under an instance/variant. The refresh flags diverges_from on shapes
new since the previous computation that sit where one canon dominates the
judged siblings of their directory+kind and were not proposed as that canon
(a first seed flags nothing); the write-path hint asks the same question
in-band for the shapes the hook names. list_shapes(flag=divergence|recheck),
shape_history(project_id, path, symbol) (read-only), coverage line/payload/
card carry divergent + recheck. Backup v8 carries the history. Plugin 0.1.36.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 23:13:39 -04:00
bvandeusenandClaude Fable 5 386b27e422 fix(ledger): the proposer prefers a same-project canon on a tie — family canon elsewhere is the fallback, not the first hit
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / Python tests (push) Successful in 1m17s
CI & Build / Python lint (push) Successful in 5s
CI & Build / integration (push) Successful in 35s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Build & push image (push) Successful in 40s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 21:36:10 -04:00
bvandeusenandClaude Fable 5 ba0030e51d feat(ledger): mechanical proposer — every refresh proposes instances against canon and groups derive-first candidates; agents confirm in batches (#2792, milestone 294 step 6)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Failing after 34s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m17s
CI & Build / Build & push image (push) Successful in 48s
Shapes now carry a content fingerprint (signature + whitespace/comment-
insensitive body_sha; migration 0080) and the proposer runs inside the
coverage refresh, the one moment bodies exist: symbol elsewhere → textual
containment → body references the canon → signature resemblance → semantic
(capped per refresh, unreached rows stay unexamined for the next). A hit is
a proposal on the row (proposed_snippet_id/basis/score), never a
classification; rows with no canon hit group by the derive-first rule
(identical body in ≥2 places, same name in ≥3 files) as proposal_basis=
derive + a group key. list_shapes(proposal=any|canon|derive|<basis>) is the
queue; confirm_shape_proposals(project_id, snippet_id|path|basis) confirms
in batches as agent instances; any classify_shapes/hook stamp retires the
proposal. Readout carries proposed + derive_groups (line, payload, card).
Plugin 0.1.35 (skill: the machine proposes, judgment classifies).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 21:30:35 -04:00
bvandeusenandClaude Fable 5 a9e1cddba7 fix(tests): provisional ledger rows carry the empty seen-marker default, not NULL
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Build & push image (push) Successful in 25s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 20:23:12 -04:00
bvandeusenandClaude Fable 5 475f0857c9 feat(ledger): write-path stamping — a pulled canon the session then instantiates lands as a hook instance row (#2791, milestone 294 step 5)
CI & Build / Plugin hooks (push) Failing after 2s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Failing after 28s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 39s
The prior-art hook now names the shapes being written (shapes=kind:name —
every definition in the payload, or the one enclosing an Edit found by
walking the file upward) and the server stamps them as instance rows when
the session PULLED a snippet inside PULL_WINDOW that the payload references
by symbol or that the semantic arm scored for this very payload.
classified_by=hook, evidence in reason; never overrides a judgment or a
canonical row, overridable by classify_shapes. Offered-but-unopened stamps
nothing. Pulled-and-already-seen snippets stay in the semantic query as
evidence without re-entering the deduped menu. A brand-new shape gets a
provisional row the next sync confirms or vanishes. Read-scoped keys get
the hint, never the stamp. Plugin 0.1.34.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 20:19:45 -04:00
bvandeusen 9d5b89d785 Merge pull request 'Coverage self-seeds + refresh_pattern_coverage tool + shape-accounting skill (#2802)' (#116) from dev into main
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 34s
CI & Build / TypeScript typecheck (push) Successful in 48s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 16s
2026-08-20 19:05:48 -04:00
bvandeusenandClaude Fable 5 3e21978a9c fix(tests): restore the assert my seed-test insertion orphaned from its neighbor
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 25s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 25s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 08:24:05 -04:00
bvandeusenandClaude Fable 5 e2a084f1fb feat(ledger): coverage self-seeds — enter_project background refresh + refresh_pattern_coverage tool + shape-accounting skill (#2802, milestone 294)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 33s
CI & Build / Build & push image (push) Skipped
The UI Refresh button must not be the only seed path (operator directive,
hit live: the P7 backfill stalled waiting for a click). Three parts:

- enter_project fire-and-forgets refresh_if_stale on the project OWNER —
  absent or day-old readouts recompute in the background (same spawn the
  webhook path uses), the enter stays fast, forge-less owners exit quietly
  (rule #115 baseline), and an in-flight guard keeps concurrent enters from
  fetching the same tarball N times.
- refresh_pattern_coverage(project_id): the synchronous agent-facing form —
  write-gated, owner-keyring resolution, and ValueError messages that name
  the fix (add a connection / bind_repo) instead of measuring nothing
  silently.
- plugin 0.1.33 ships the shape-accounting skill: the five statuses, the
  seed/todo/judge loop, and the derive-first rule, triggered by the
  coverage line or any proved code-to-canon relationship.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 08:18:36 -04:00
bvandeusen fd101d24ae Merge pull request 'Shape ledger steps 1-4 + direct-minting Systems bootstrap (milestone 294, #2798)' (#115) from dev into main
CI & Build / TypeScript typecheck (push) Successful in 50s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m14s
CI & Build / Build & push image (push) Successful in 16s
2026-08-19 21:24:40 -04:00
bvandeusenandClaude Fable 5 9f1a52a035 feat(ledger): audits write rows, not prose — instruction surfaces carry the classification duty (#2790, milestone 294 step 4)
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 27s
verify_snippet docstring: consumers enumerated while checking are
classify_shapes instance rows; detail keeps the WHY and what changed,
nothing that belongs in a row. The MCP _INSTRUCTIONS REUSE bullet gains
"classify shapes against canon — a consumer map is rows, never prose"
(budget rebalanced to 1,998/2,000: the rules bullet lost its historical
push-optimisation clause, the task-note line tightened). The reusing-code
skill gains the full contract: instance/variant/exempt with required
reasons, list_shapes(status=unclassified) as the standing todo, and the
derive-one-first rule for repeating shapes with no canon. Plugin 0.1.32.

The operator-side halves of this step live outside the repo: the Drift
Audit process gained step 7 (classify what the walk proved) and the DRY
Pass s9 now sends consumer maps to the ledger with the note keeping only
the narrative. The Forge P7 backfill payload is enumerated and parked on
task 2790 — it fires after the next deploy + coverage refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 21:16:04 -04:00
bvandeusenandClaude Fable 5 5265d11a6a feat(systems): bootstrap mints directly — the standard vocabulary replaces operator approval (#2798)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 40s
CI & Build / Python tests (push) Successful in 1m5s
CI & Build / Build & push image (push) Successful in 59s
Operator directive: creating Systems is the agent's call, not an approval
flow — "propose to the operator, create each confirmed one" made the
operator a permission gate. The bootstrap ask now says create_system 3-6
directly, in-session, and the consistency that approval was covering moves
to a standard cross-project vocabulary (CI & Release, Auth & Access, Data
Model & Storage, API Surface, UI & Design, Import & Export, Background
Jobs, Observability): use the standard name verbatim where the area fits,
mint freely beyond it, the duplicate gate guards sprawl. create_system and
enter_project docstrings carry the same contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 20:05:02 -04:00
bvandeusenandClaude Fable 5 d50ebbad66 fix(mcp): classify list_shapes as read-only for read-scoped API keys
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 20s
CI & Build / TypeScript typecheck (push) Successful in 24s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 32s
The auth guard test caught it: a read-shaped tool in neither set fails
closed for read keys, silently. list_shapes reads the ledger and writes
nothing; classify_shapes stays write-scoped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 20:02:12 -04:00
bvandeusenandClaude Fable 5 942edd1eb5 feat(ledger): classify_shapes + list_shapes MCP tools; get_snippet carries the consumer map (#2789, milestone 294 step 3)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 27s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Failing after 42s
CI & Build / Build & push image (push) Skipped
The judgment write path. classify_shapes applies a batch of classifications
to a project's live ledger rows — all-or-nothing (#2709's lesson: the whole
batch is validated, write-ACL'd, and every snippet target proven readable
before any row is touched); rows match by exact (path, symbol), kind narrows,
and shapes no live row matches come back as 'unmatched' rather than errors.
variant/exempt REQUIRE the reason — the why is the record (note 2786) — and
'unclassified' deliberately withdraws a judgment back to the todo. The 'via'
channel is caller-restricted to agent|audit|import; hook and mechanical stay
server-internal so a caller can't launder judgment as machinery.

list_shapes is the todo query (status=unclassified) with composable filters:
path is exact-or-under like recorded locations, snippet_id reads a consumer
map, include_vanished reads history; paged with the true total.

get_snippet now attaches  and  — the structured consumer
map, filtered to projects the CALLER can read so a shared snippet never side-
channels another project's file layout; attached only when non-empty (#2483).

Integration tests pin the batch atomicity, ACL gates, filter composition, the
consumer map on the MCP pull, and the SET NULL companion: a judgment whose
snippet was purged rejoins the todo on the next sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:55:47 -04:00
bvandeusenandClaude Fable 5 9d92df2825 fix(tests): drop the pre-ledger line-ending assertion the step-2 edit missed
CI & Build / Python lint (push) Successful in 4s
CI & Build / Build & push image (push) Successful in 22s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / integration (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m1s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:43:11 -04:00
bvandeusenandClaude Fable 5 9b1597a3c9 feat(ledger): coverage refresh feeds the shape ledger; the readout inverts to accounting (#2788, milestone 294 step 2)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Failing after 27s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 44s
compute_coverage is now the ledger's sync point: every walk upserts the
extracted shapes (new → unclassified, the todo state; surviving → last-seen
bump; vanished → stamped, kept as history), re-files judgments whose snippet
target went away, and mechanically stamps snippet reference locations as
canonical — the one always-safe rule, self-healing only for its own stamps
(an agent's judgment is never unwound by machinery).

The covering predicate moves to shape_ledger.location_covers as the single
home (match_shapes retired with its consumer); coverage's payload and line
invert from 'N/M shapes recorded' to shape ACCOUNTING per note 2786:
accounted/total with a canonical·instance·variant·exempt breakdown, and
unclassified — THE todo — with its largest directories. Cache key bumps to
v2 so pre-ledger blobs honestly read 'not measured yet' instead of rendering
in a shape no longer spoken.

Readout is deliberately project-wide (all repos' live rows), while the walk
serves whichever repos the owner's keyring reaches this refresh.

Integration tests pin the new contract: rows for every extracted shape,
mechanical canonical stamps carrying snippet ids, idempotent recompute,
agent judgments surviving recompute AND vanish/return, vanished rows leaving
the readout but keeping their history.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:37:18 -04:00
bvandeusenandClaude Fable 5 19fdc9aa89 feat(ledger): code_shapes — the shape ledger schema (#2787, milestone 294 step 1)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 13s
CI & Build / integration (push) Successful in 25s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 45s
The accounting half of the pattern system (governing note 2786): the snippet
library records canon (small), this table accounts for EVERY extracted shape
(total). Identity is (project, repo_key, path, symbol, kind) — kind included
because one file can define '.foo' (css) and 'foo' (sym) as distinct shapes.
Status vocabulary: canonical / instance / variant / exempt / unclassified,
with unclassified as the default and THE todo state; classifications carry
who judged (agent|audit|hook|mechanical|import), when, and the why for
variants/exemptions. first/last-seen commits + vanished_at keep history
instead of deleting it; a rename reads as vanish+new (accepted for v1).

snippet_id is SET NULL on snippet deletion so accounting rows outlive their
target and rejoin the todo via the step-2 sync, never dangle silently.

Backups: v7 carries code_shapes (judgment data, worth moving) — full and
per-user export sections, and a restore that keeps a judgment only when its
snippet survives the id re-mapping, downgrading to unclassified otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:25:25 -04:00
bvandeusen c1cb8fb183 Merge pull request 'Per-user forge connections — keyring, host-keyed resolution, project pin (#2778)' (#114) from dev into main
CI & Build / Python lint (push) Successful in 6s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m7s
CI & Build / Build & push image (push) Successful in 20s
2026-08-19 15:24:47 -04:00
bvandeusenandClaude Fable 5 1faf8f3ece feat(forge): per-user forge connections — keyring, host-keyed resolution, project pin (#2778)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 41s
CI & Build / integration (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 40s
A forge token is a user's credential, not an instance's. The single
admin-settings config is replaced by per-user keyring rows (one per forge
host), and every server-side forge read runs on the PROJECT OWNER's keyring:

- forge_connections table + projects.forge_connection_id pin (migration 0078,
  which also carries the existing admin config into the first admin's row and
  deletes the old setting keys — no legacy dual-read)
- get_forge() replaced by get_forges(owner_id, project_id) -> ForgeSelector;
  resolve(repo) picks the connection whose host serves the repo. A pinned
  project uses ONLY its pinned connection; a stale pin (ownership moved) is
  ignored, never honored across users
- env FORGE_* config survives as an implicit entry for admin owners only;
  a stored row for the same host beats it
- consumers threaded: pull-time freshness (owner of the note), coverage
  (owner of the project), coverage routes' configured flag
- routes: /api/settings/forge-connections CRUD + per-connection test
  (own-rows only, tokens never returned); /api/admin/forge shrinks to
  /api/admin/forge-webhook (secret only); PUT /api/projects/<id>/forge pins,
  owner-or-admin asking, owner's connections only
- UI: Git Forges card moves to Settings -> Integrations as a connection
  list; webhook secret stays in the admin Config tab; owner-only forge
  select on the project coverage card
- backups exclude forge_connections (credentials, api_keys precedent) and
  the pin, so restores fall back to keyring resolution

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:23:22 -04:00
bvandeusen 5c14c2621c Merge pull request 'Strict MCP arguments (#2709) + zero-Systems bootstrap ask (#2683)' (#113) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 19s
2026-08-17 16:26:32 -04:00
bvandeusenandClaude Fable 5 7a5e2b18d9 feat(systems): evidence-carrying bootstrap ask for mature zero-Systems projects (#2683)
CI & Build / Build & push image (push) Successful in 39s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m4s
The generic zero-state systems_hint never converts: identical on every
record, maximal in scope, asked at wrap-up time — Minstrel reached 282
records with zero Systems while vocabularied projects grew organically.
What converts is the project's own evidence at the moment of action.

bootstrap_systems_ask (mcp/tools/systems.py) fires only in a project
with >=20 records and no Systems: it names the record count and recent
titles, and asks for a concrete deliverable — propose 3-6 Systems,
confirm with the operator, create_system the set. Self-retiring: the
first System ends it everywhere. Wired at both moments the task named:
untagged_systems_hint escalates to it at write time, and enter_project
carries it as systems_bootstrap at arrival (attached only when it
applies). Young projects keep the mild question; populated vocabularies
never pay the count query.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 14:53:46 -04:00
bvandeusenandClaude Fable 5 d6c9f08a59 fix(mcp): reject undeclared tool arguments instead of silently dropping them (#2709)
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 37s
FastMCP validates tool arguments with a pydantic model whose extra-field
policy is 'ignore', so create_note(content=...) — a plausible near-miss
for body=, primed by add_task_log's content — ran successfully, stored
body: '', and left a record embedding/search cannot see. Two real notes
were persisted body-less before the pattern was noticed; create_task
only 'worked' because those calls happened to use the right name.

StrictArgsFastMCP rejects any tool call carrying arguments the tool does
not declare, before dispatch, with a did-you-mean hint when one is close
and the declared list when none is. Applied at the dispatch seam so
every tool gets the guarantee — an error the caller sees once beats data
half-written forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 12:54:58 -04:00
bvandeusen 25411cf223 Merge pull request 'Edit-time record-sync nudge — the sync class (#2708)' (#112) 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 25s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 1m2s
CI & Build / Build & push image (push) Successful in 18s
2026-08-16 20:09:38 -04:00
bvandeusenandClaude Fable 5 3162332a13 feat(prior-art): edit-time record-sync nudge — the sync class (#2708)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 24s
CI & Build / integration (push) Successful in 26s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 43s
A snippet recorded AT the exact file being edited is not a reuse
suggestion — it IS the record of the file being changed. The write-path
hint now renders those as their own SYNC class: 'snippet #N records this
file — updating the record is part of the edit (update_snippet /
verify_snippet)'. Nearby and semantic hits stay the reuse menu.

The two classes dedup on separate per-session channels (exclude_ids vs
exclude_sync_ids, .ids vs .sync.ids in the hook), so a reuse hint shown
early in a session can no longer silence the record-sync nudge when the
recorded file itself is edited later. Sync surfacing is measured under
its own note_usage source (write_path_sync) — its pull-through rate is
the scoreboard for whether edit-time sync actually happens, per decision
#2707 (no forge connection; records stay current in the session that has
the context). Plugin 0.1.31.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 20:05:01 -04:00
172 changed files with 9062 additions and 3517 deletions
+130
View File
@@ -0,0 +1,130 @@
"""Forge connections move to the user level (#2778)
Revision ID: 0078
Revises: 0077
Create Date: 2026-08-19
A forge token is a user's credential, not an instance's: the single
admin-settings config meant every user's snippet-freshness and coverage reads
ran under the operator's token. Each user now owns a keyring of connections —
one per forge host — and projects resolve forge reads on their OWNER's
keyring, with an optional per-project pin (projects.forge_connection_id).
The data move carries the existing admin config into a connection row for the
first admin user (host parsed from the base URL), then deletes the old
setting keys outright — no legacy dual-read (rule #22). The env-var channel
(FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) is untouched by this migration; it
survives as an implicit keyring entry for admin users only.
"""
from urllib.parse import urlsplit
import sqlalchemy as sa
from alembic import op
revision = "0078"
down_revision = "0077"
branch_labels = None
depends_on = None
_SETTING_KEYS = ("forge_kind", "forge_base_url", "forge_token")
def upgrade() -> None:
op.create_table(
"forge_connections",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("base_url", sa.Text(), nullable=False),
sa.Column("host", sa.Text(), nullable=False),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("user_id", "host", name="uq_forge_connections_user_host"),
)
op.add_column(
"projects",
sa.Column(
"forge_connection_id",
sa.BigInteger(),
sa.ForeignKey(
"forge_connections.id",
ondelete="SET NULL",
name="fk_projects_forge_connection_id",
),
nullable=True,
),
)
# Data move: the admin-settings config becomes the first admin's keyring
# row. All three values must be present — a partial config never produced
# an adapter, so carrying it over would invent a connection that never
# worked.
conn = op.get_bind()
row = conn.execute(
sa.text(
"SELECT s.key, s.value FROM settings s"
" JOIN users u ON u.id = s.user_id"
" WHERE u.role = 'admin' AND s.key IN :keys"
" AND s.user_id = ("
" SELECT MIN(id) FROM users WHERE role = 'admin'"
" )"
).bindparams(sa.bindparam("keys", expanding=True)),
{"keys": list(_SETTING_KEYS)},
).fetchall()
values = {key: (value or "").strip() for key, value in row}
kind = values.get("forge_kind", "").lower()
base_url = values.get("forge_base_url", "").rstrip("/")
token = values.get("forge_token", "")
host = (urlsplit(base_url).hostname or "").lower()
if kind and base_url and token and host:
conn.execute(
sa.text(
"INSERT INTO forge_connections"
" (user_id, kind, base_url, host, token, created_at, updated_at)"
" SELECT MIN(id), :kind, :base_url, :host, :token, NOW(), NOW()"
" FROM users WHERE role = 'admin'"
),
{"kind": kind, "base_url": base_url, "host": host, "token": token},
)
conn.execute(
sa.text(
"DELETE FROM settings WHERE key IN :keys"
).bindparams(sa.bindparam("keys", expanding=True)),
{"keys": list(_SETTING_KEYS)},
)
def downgrade() -> None:
# Reverse data move: the first admin's row (if any) becomes the admin
# settings again. Other users' rows have no pre-0078 representation and
# are dropped with the table.
conn = op.get_bind()
row = conn.execute(
sa.text(
"SELECT user_id, kind, base_url, token FROM forge_connections"
" WHERE user_id = (SELECT MIN(id) FROM users WHERE role = 'admin')"
" ORDER BY id LIMIT 1"
)
).fetchone()
if row is not None:
for key, value in (
("forge_kind", row.kind),
("forge_base_url", row.base_url),
("forge_token", row.token),
):
conn.execute(
sa.text(
"INSERT INTO settings (user_id, key, value)"
" VALUES (:uid, :key, :value)"
" ON CONFLICT (user_id, key) DO UPDATE SET value = :value"
),
{"uid": row.user_id, "key": key, "value": value},
)
op.drop_column("projects", "forge_connection_id")
op.drop_table("forge_connections")
@@ -0,0 +1,66 @@
"""The shape ledger: code_shapes (#2787, milestone 294)
Revision ID: 0079
Revises: 0078
Create Date: 2026-08-19
The accounting half of the pattern system (governing note 2786): the snippet
library records canon (small); this table accounts for EVERY shape the
coverage extractor finds in a bound repo (total). Rows arrive `unclassified`
from the coverage sync (step 2) and gain judgments — canonical / instance /
variant / exempt — from audits, hooks, and the mechanical proposer.
Unclassified IS the todo list.
"""
import sqlalchemy as sa
from alembic import op
revision = "0079"
down_revision = "0078"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"code_shapes",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"project_id",
sa.Integer(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("repo_key", sa.Text(), nullable=False),
sa.Column("path", sa.Text(), nullable=False),
sa.Column("symbol", sa.Text(), nullable=False),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False, server_default="unclassified"),
sa.Column(
"snippet_id",
sa.BigInteger(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("classified_by", sa.Text(), nullable=True),
sa.Column("classified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("first_seen_commit", sa.Text(), nullable=False, server_default=""),
sa.Column("last_seen_commit", sa.Text(), nullable=False, server_default=""),
sa.Column("vanished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint(
"project_id", "repo_key", "path", "symbol", "kind",
name="uq_code_shapes_identity",
),
)
op.create_index(
"ix_code_shapes_project_status", "code_shapes", ["project_id", "status"]
)
op.create_index("ix_code_shapes_snippet", "code_shapes", ["snippet_id"])
def downgrade() -> None:
op.drop_index("ix_code_shapes_snippet", table_name="code_shapes")
op.drop_index("ix_code_shapes_project_status", table_name="code_shapes")
op.drop_table("code_shapes")
@@ -0,0 +1,54 @@
"""Shape fingerprints + the mechanical proposer's columns (#2792, milestone 294)
Revision ID: 0080
Revises: 0079
Create Date: 2026-08-21
Two additions to the ledger. `signature` / `body_sha` fingerprint each shape
(definition line + a whitespace/comment-insensitive hash of its block) so the
proposer can match on content and a later drift recheck can notice change,
without the ledger ever storing code. The proposal columns carry the
proposer's standing suggestion for an unclassified row — instance-of-#N with
a basis and score, or a derive-first group key — and `proposed_sha`
remembers the content it was judged at so a refresh re-examines only what
changed. Mechanical and recomputable: a restore that lacks them loses
nothing the next refresh does not rebuild.
"""
import sqlalchemy as sa
from alembic import op
revision = "0080"
down_revision = "0079"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("code_shapes", sa.Column("signature", sa.Text(), nullable=False, server_default=""))
op.add_column("code_shapes", sa.Column("body_sha", sa.Text(), nullable=False, server_default=""))
op.add_column(
"code_shapes",
sa.Column(
"proposed_snippet_id",
sa.BigInteger(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
)
op.add_column("code_shapes", sa.Column("proposal_basis", sa.Text(), nullable=True))
op.add_column("code_shapes", sa.Column("proposal_score", sa.Float(), nullable=True))
op.add_column("code_shapes", sa.Column("proposal_group", sa.Text(), nullable=True))
op.add_column("code_shapes", sa.Column("proposed_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("code_shapes", sa.Column("proposed_sha", sa.Text(), nullable=False, server_default=""))
op.create_index(
"ix_code_shapes_proposed", "code_shapes", ["project_id", "proposed_snippet_id"]
)
def downgrade() -> None:
op.drop_index("ix_code_shapes_proposed", table_name="code_shapes")
for col in (
"proposed_sha", "proposed_at", "proposal_group", "proposal_score",
"proposal_basis", "proposed_snippet_id", "body_sha", "signature",
):
op.drop_column("code_shapes", col)
@@ -0,0 +1,70 @@
"""Shape history, recheck, and the divergence flag (#2793, milestone 294)
Revision ID: 0081
Revises: 0080
Create Date: 2026-08-21
The payoff surface of the ledger. `classified_sha` remembers the fingerprint
a judgment was made at so a later body change under an instance/variant can
flag `recheck_at`; `diverges_from` is the button-B flag (a shape new since
the previous refresh, where one canon dominates its directory+kind, and not
proposed as that canon). `code_shape_events` is the what-was-used-when
record: every classification, vanish, reappearance, and drift as it
happened — history the row alone cannot keep once it moves on.
"""
import sqlalchemy as sa
from alembic import op
revision = "0081"
down_revision = "0080"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("code_shapes", sa.Column("classified_sha", sa.Text(), nullable=False, server_default=""))
op.add_column("code_shapes", sa.Column("recheck_at", sa.DateTime(timezone=True), nullable=True))
op.add_column(
"code_shapes",
sa.Column(
"diverges_from",
sa.BigInteger(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index("ix_code_shapes_diverges", "code_shapes", ["project_id", "diverges_from"])
op.create_table(
"code_shape_events",
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("project_id", sa.Integer(), nullable=False),
sa.Column("path", sa.Text(), nullable=False),
sa.Column("symbol", sa.Text(), nullable=False),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("event", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=True),
sa.Column("snippet_id", sa.BigInteger(), nullable=True),
sa.Column("classified_by", sa.Text(), nullable=True),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("commit", sa.Text(), nullable=False, server_default=""),
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_code_shape_events_shape", "code_shape_events", ["shape_id", "at"])
op.create_index(
"ix_code_shape_events_project_path", "code_shape_events", ["project_id", "path"]
)
def downgrade() -> None:
op.drop_index("ix_code_shape_events_project_path", table_name="code_shape_events")
op.drop_index("ix_code_shape_events_shape", table_name="code_shape_events")
op.drop_table("code_shape_events")
op.drop_index("ix_code_shapes_diverges", table_name="code_shapes")
for col in ("diverges_from", "recheck_at", "classified_sha"):
op.drop_column("code_shapes", col)
+26
View File
@@ -0,0 +1,26 @@
"""Per-binding ref — the branch a project's ledger follows (#2873, milestone 294)
Revision ID: 0082
Revises: 0081
Create Date: 2026-08-21
A repo binding used to imply the repo's default branch; the shape ledger
therefore only saw work after a merge to main, while the operator's work
lands on dev (rule 1). `ref` names the branch the coverage refresh reads —
NULL keeps today's behaviour (the forge's default branch).
"""
import sqlalchemy as sa
from alembic import op
revision = "0082"
down_revision = "0081"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("repo_bindings", sa.Column("ref", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("repo_bindings", "ref")
@@ -0,0 +1,26 @@
"""Exempt/variant reason codes — a small fixed catalogue beside the prose (#2874, milestone 294)
Revision ID: 0083
Revises: 0082
Create Date: 2026-08-21
The 2026-08 audit wrote the same free-text reason thousands of times
("scoped rule — styles one element of this view"); a judgment's WHY stays
prose, but an optional code from a fixed catalogue makes the ledger
filterable and aggregable ("how many pure helpers, how many test helpers").
"""
import sqlalchemy as sa
from alembic import op
revision = "0083"
down_revision = "0082"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("code_shapes", sa.Column("reason_code", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("code_shapes", "reason_code")
+40
View File
@@ -0,0 +1,40 @@
"""code_shape_uses — consumption edges, separate from conformance (#2870, milestone 294)
Revision ID: 0084
Revises: 0083
Create Date: 2026-08-21
A ledger row carries ONE snippet_id: what shape this is (instance/variant of
a canon). But a shape can also CALL several canonical helpers — a service
function that is an instance of the service-function convention and a
consumer of hash_token. The 2026-08 audit had to pick one; hook evidence
("pulled #N then wrote code referencing it") was stamped as instance when it
is a uses fact. This table holds the many-valued relation: shape → snippet,
with the basis and the evidence. Cascades with the shape and the snippet.
"""
import sqlalchemy as sa
from alembic import op
revision = "0084"
down_revision = "0083"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"code_shape_uses",
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("snippet_id", sa.Integer(), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
sa.Column("basis", sa.Text(), nullable=False),
sa.Column("evidence", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.UniqueConstraint("shape_id", "snippet_id", name="uq_code_shape_uses_shape_snippet"),
)
op.create_index("ix_code_shape_uses_snippet", "code_shape_uses", ["snippet_id"])
def downgrade() -> None:
op.drop_index("ix_code_shape_uses_snippet", table_name="code_shape_uses")
op.drop_table("code_shape_uses")
+14
View File
@@ -38,6 +38,20 @@ async function handleResponse<T>(res: Response, path: string): Promise<T> {
return res.json() as Promise<T>;
}
/**
* The server's `{"error": "..."}` message from a failed call, or `fallback`
* when the failure carried none (network error, non-JSON body). The one place
* the error envelope is unpacked on the client — views used to restate this
* as a six-line `"body" in e` branch at every catch site.
*/
export function apiErrorMessage(e: unknown, fallback: string): string {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: unknown } }).body;
if (body && typeof body.error === "string" && body.error) return body.error;
}
return fallback;
}
export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(path);
return handleResponse<T>(res, path);
+115
View File
@@ -0,0 +1,115 @@
/* ── Auth surface (Login / Register / RegisterInvite / ForgotPassword / ResetPassword) ──
The five auth views used to carry byte-identical copies of these rules in
their scoped blocks (2026-08 shape audit). Loaded per view with
<style src="@/assets/auth-shared.css" />, like editor-shared.css; the form
rules are scoped under .auth-card so nothing leaks into the app's other
.field/.input usages. Per-view one-offs (Login's .divider/.forgot-link)
stay in the view. */
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin-bottom: 1rem;
}
.auth-hint a {
color: var(--fs-accent);
}
/* A centred status paragraph block: registration closed, invalid/expired
token, "check your inbox". One rule — the views used to name it
.closed-msg / .error-block / .success-msg with identical bodies. */
.auth-note {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.auth-note p {
margin: 0.5rem 0;
}
.auth-loading {
text-align: center;
color: var(--fs-text-tertiary);
font-size: 0.95rem;
padding: 1rem 0;
}
.auth-card .field {
margin-bottom: 1rem;
}
.auth-card .field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.auth-card .input {
width: 100%;
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;
}
.auth-card .input:focus {
outline: none;
border-color: var(--fs-accent);
}
.auth-card .input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-card .input-error,
.auth-card .input-error:focus {
border-color: var(--fs-error);
}
.auth-card .field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.auth-card .error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
}
.auth-card .error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
+76
View File
@@ -221,3 +221,79 @@
is the page's main action */
font-size: var(--fs-size-body-sm);
}
/* ── Modal ─────────────────────────────────────────────────────────────────
The one overlay/card/button shape for every in-app dialog (ConfirmDialog,
the create-project / merge-snippet / systems dialogs, the editors' confirm
prompts). Global on purpose: ConfirmDialog teleports to <body> and has no
styles of its own, so these must be loaded with the app, not with whichever
view happens to be open. Views add only their own overrides (a wider card,
a form layout). Destructive = action-destructive per the Hybrid rule. */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title {
margin: 0 0 0.75rem;
font-size: 1.05rem;
}
.modal-message {
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 0 0 1.25rem;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--fs-surface-page);
}
.modal-btn-primary {
background: var(--fs-action-primary);
border-color: var(--fs-action-primary);
color: var(--fs-text-on-action);
}
.modal-btn-primary:hover:not(:disabled) {
background: var(--fs-action-primary-hover);
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.modal-btn-danger {
background: var(--fs-action-destructive);
border-color: var(--fs-action-destructive);
color: var(--fs-text-on-action);
}
.modal-btn-danger:hover {
background: var(--fs-action-destructive-hover);
border-color: var(--fs-action-destructive-hover);
}
-47
View File
@@ -316,53 +316,6 @@
gap: 0.5rem;
}
/* ── Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
max-width: 400px;
width: 90%;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.modal-message {
margin: 0 0 1.25rem;
color: var(--fs-text-secondary);
font-size: 0.95rem;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.modal-btn {
padding: 0.45rem 1rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
cursor: pointer;
font-size: 0.9rem;
}
.modal-btn-danger {
background: var(--fs-error);
color: var(--fs-text-on-action);
border-color: var(--fs-error);
}
/* ── Floating inline assist button (teleported to body) ── */
.inline-assist-btn {
position: fixed;
+2 -9
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
import DiffView from "@/components/DiffView.vue";
import type { DiffLine } from "@/composables/useAssist";
import { fmtStamp } from "@/utils/dateFormat";
interface NoteVersion {
id: number;
@@ -56,14 +57,6 @@ const diff = computed<DiffLine[]>(() => {
return result;
});
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
async function loadVersions() {
loading.value = true;
try {
@@ -212,7 +205,7 @@ onMounted(loadVersions);
v-if="v.pin_kind === 'manual' && v.pin_label"
class="history-item-label"
>{{ v.pin_label }}</div>
<div class="history-item-date">{{ formatDate(v.created_at) }}</div>
<div class="history-item-date">{{ fmtStamp(v.created_at) }}</div>
</div>
</div>
@@ -515,36 +515,4 @@ async function confirmDelete() {
}
.skel-row--short { width: 65%; }
/* ── Modal ────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed; inset: 0;
background: var(--fs-overlay);
display: flex; align-items: center; justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
.modal-message { font-size: 0.9rem; color: var(--fs-text-secondary); margin: 0 0 1.25rem; line-height: 1.5; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover { background: var(--fs-surface-page); }
.modal-btn-danger { background: var(--fs-action-destructive); border-color: var(--fs-action-destructive); color: var(--fs-text-on-action); }
.modal-btn-danger:hover { background: var(--fs-action-destructive-hover); border-color: var(--fs-action-destructive-hover); }
</style>
+2 -8
View File
@@ -3,6 +3,7 @@ import { ref, onMounted } from "vue";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
import { renderMarkdown } from "@/utils/markdown";
import type { TaskLog } from "@/types/task";
import { fmtStamp } from "@/utils/dateFormat";
const props = defineProps<{ taskId: number }>();
@@ -15,13 +16,6 @@ const editingId = ref<number | null>(null);
const editContent = ref("");
const editDuration = ref("");
function formatDate(iso: string): string {
const d = new Date(iso);
const datePart = d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
const timePart = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
return `${datePart}, ${timePart}`;
}
function formatDuration(minutes: number): string {
if (minutes < 60) return `${minutes} min`;
const h = Math.floor(minutes / 60);
@@ -128,7 +122,7 @@ onMounted(loadLogs);
</template>
<template v-else>
<div class="log-entry-meta">
<span class="log-date">{{ formatDate(log.created_at) }}</span>
<span class="log-date">{{ fmtStamp(log.created_at) }}</span>
<span v-if="log.duration_minutes" class="log-duration-badge">
{{ formatDuration(log.duration_minutes) }}
</span>
@@ -11,6 +11,7 @@ import TagInput from "@/components/TagInput.vue";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import WordCount from "@/components/WordCount.vue";
import { Trash2, X } from "lucide-vue-next";
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
const props = defineProps<{
projectId: number;
@@ -252,20 +253,6 @@ async function confirmDelete(id: number) {
}
}
function formatDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHrs = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHrs < 24) return `${diffHrs}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
watch(noteTitle, () => { dirty.value = true; });
watch(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); });
watch(noteTags, () => { dirty.value = true; });
@@ -346,7 +333,7 @@ defineExpose({ reload: loadProjectNotes });
>
<div class="note-row-main">
<span class="note-row-title">{{ note.title || 'Untitled' }}</span>
<span class="note-row-age">{{ formatDate(note.updated_at) }}</span>
<span class="note-row-age">{{ relativeTimeOrDate(note.updated_at) }}</span>
</div>
<div v-if="note.tags?.length" class="note-row-tags">
<span
+3 -16
View File
@@ -6,6 +6,7 @@ import { useToastStore } from "@/stores/toast";
import TaskLogSection from "@/components/TaskLogSection.vue";
import { renderMarkdown } from "@/utils/markdown";
import { Trash2, X } from "lucide-vue-next";
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
const props = defineProps<{ projectId: number }>();
@@ -198,20 +199,6 @@ function cancelDeleteTask() {
deleteConfirmPending.value = false;
}
function formatDate(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHrs = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHrs < 24) return `${diffHrs}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
onMounted(loadAll);
defineExpose({ reload: loadAll });
</script>
@@ -256,7 +243,7 @@ defineExpose({ reload: loadAll });
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
</li>
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
</ul>
@@ -281,7 +268,7 @@ defineExpose({ reload: loadAll });
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
</li>
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
</ul>
@@ -9,3 +9,15 @@ export function relativeTime(iso: string): string {
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
/**
* relativeTime() for the recent past, a short date once it's a week old —
* the workspace panels' list-row timestamp ("3h ago" / "Jan 15"). Two
* panels used to carry identical copies of this.
*/
export function relativeTimeOrDate(iso: string): string {
const d = new Date(iso);
const days = Math.floor((Date.now() - d.getTime()) / 86_400_000);
if (days < 7) return relativeTime(iso);
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
+24 -57
View File
@@ -1,65 +1,32 @@
/** Shared date/time formatting helpers used across Calendar, Home, Knowledge, etc. */
function _isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
}
/** "9:30 AM" */
export function fmtTime(dt: string): string {
return new Date(dt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
}
/** "Mon, Jan 15" or "Mon, Jan 15, 9:30 AM" */
export function fmtDateTime(dt: string, allDay: boolean): string {
const d = new Date(dt)
const datePart = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
if (allDay) return datePart
return `${datePart}, ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
}
/**
* "Today 9:30 AM" / "Tomorrow 9:30 AM" / "Mon, Jan 15 9:30 AM"
* For all-day events returns "Today" / "Tomorrow" / "Mon, Jan 15"
* Shared date/time formatting — one rule per display shape. Views import
* these instead of carrying a local formatDate(): the 2026-08 shape audit
* found eight copies across views/components, three of them byte-identical.
* (The previous Calendar/Home helpers in this file had no callers left and
* were removed in the same pass.)
*
* Relative forms ("5m ago") live next door in composables/useRelativeTime.
*/
export function fmtRelativeDateTime(dt: string, allDay: boolean): string {
try {
const d = new Date(dt)
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(now.getDate() + 1)
const timeStr = allDay ? "" : ` ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
if (_isSameDay(d, now)) return `Today${timeStr}`
if (_isSameDay(d, tomorrow)) return `Tomorrow${timeStr}`
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + timeStr
} catch {
return dt
}
/** "Jan 15, 2026" — a date with no time of day (user created_at, key expiry). */
export function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric", month: "short", day: "numeric",
});
}
/**
* Label-only: "Today" / "Tomorrow" / "Mon, Jan 15"
*/
export function fmtDayLabel(dt: string): string {
try {
const d = new Date(dt)
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(now.getDate() + 1)
if (_isSameDay(d, now)) return "Today"
if (_isSameDay(d, tomorrow)) return "Tomorrow"
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
} catch {
return dt
}
/** "Jan 15, 2026, 09:30 AM" — a full timestamp (task logs, version history). */
export function fmtStamp(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: "short", day: "numeric", year: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
/** "Jan 15" or "Jan 15, 9:30 AM" — compact, no weekday */
export function fmtCompact(dt: string, allDay: boolean): string {
const d = new Date(dt)
if (allDay) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
/** "Jan 15, 09:30:05 AM" — log-table timestamp: seconds matter, the year doesn't. */
export function fmtLogStamp(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
}
+4 -88
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from "vue";
import { apiPost } from "@/api/client";
import { apiPost, apiErrorMessage } from "@/api/client";
import AppLogo from "@/components/AppLogo.vue";
const email = ref("");
@@ -15,12 +15,7 @@ async function handleSubmit() {
await apiPost("/api/auth/forgot-password", { email: email.value });
submitted.value = true;
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Something went wrong";
} else {
error.value = "Something went wrong";
}
error.value = apiErrorMessage(e, "Something went wrong");
} finally {
submitting.value = false;
}
@@ -55,7 +50,7 @@ async function handleSubmit() {
</form>
</template>
<div v-else class="success-msg">
<div v-else class="auth-note">
<p>If an account exists with that email address, you will receive a password reset link shortly.</p>
<p>Check your email and follow the instructions to reset your password.</p>
</div>
@@ -67,83 +62,4 @@ async function handleSubmit() {
</main>
</template>
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin-bottom: 1rem;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
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;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.success-msg {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.success-msg p {
margin: 0.5rem 0;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
</style>
<style src="@/assets/auth-shared.css" />
+3 -78
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
import { apiErrorMessage } from "@/api/client";
const router = useRouter();
const route = useRoute();
@@ -30,12 +31,7 @@ async function handleSubmit() {
const redirect = (route.query.redirect as string) || "/";
router.push(redirect);
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Login failed";
} else {
error.value = "Login failed";
}
error.value = apiErrorMessage(e, "Login failed");
} finally {
submitting.value = false;
}
@@ -112,70 +108,8 @@ function loginWithOAuth() {
</main>
</template>
<style src="@/assets/auth-shared.css" />
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin-bottom: 1rem;
}
.auth-hint a {
color: var(--fs-accent);
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
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;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.divider {
display: flex;
align-items: center;
@@ -190,15 +124,6 @@ function loginWithOAuth() {
flex: 1;
border-top: 1px solid var(--fs-border-color);
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
.forgot-link {
text-align: right;
margin: -0.5rem 0 0.75rem;
+2 -12
View File
@@ -3,6 +3,7 @@ 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();
@@ -98,17 +99,6 @@ function toggleExpand(id: number) {
expandedId.value = expandedId.value === id ? null : id;
}
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function formatDetails(details: string | null): string {
if (!details) return "";
try {
@@ -210,7 +200,7 @@ function clearFilters() {
:class="{ 'row-expanded': expandedId === entry.id }"
@click="toggleExpand(entry.id)"
>
<td class="cell-time">{{ formatTime(entry.created_at) }}</td>
<td class="cell-time">{{ fmtLogStamp(entry.created_at) }}</td>
<td>
<span class="category-badge" :class="'cat-' + entry.category">
{{ entry.category }}
-46
View File
@@ -559,24 +559,8 @@ function overallPct(project: Project): { total: number; pct: number } {
color: var(--fs-text-tertiary);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 480px;
box-shadow: 0 8px 32px var(--color-shadow);
display: flex;
flex-direction: column;
gap: 1rem;
@@ -618,36 +602,6 @@ function overallPct(project: Project): { total: number; pct: number } {
.modal-textarea {
resize: vertical;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--fs-surface-page);
}
.modal-btn-primary {
background: var(--fs-action-primary);
border-color: var(--fs-action-primary);
color: var(--fs-text-on-action);
}
.modal-btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
@media (max-width: 600px) {
.projects-grid {
+164 -38
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client";
import { apiGet, apiPatch, apiDelete, apiPost, apiPut } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { useTasksStore } from "@/stores/tasks";
import { relativeTime } from "@/composables/useRelativeTime";
@@ -48,6 +49,7 @@ interface Project {
status: "active" | "paused" | "completed" | "archived";
color: string | null;
design_system_id: number | null;
forge_connection_id: number | null;
permission?: string;
created_at: string;
updated_at: string;
@@ -422,16 +424,40 @@ async function loadNotes() {
interface CoverageGap {
dir: string;
uncovered: number;
unclassified: number;
total: number;
}
interface DeriveGroup {
group: string;
kind: string;
label: string;
size: number;
paths: string[];
}
interface Coverage {
total: number;
recorded: number;
accounted: number;
unclassified: number;
counts: Record<string, number>;
estimate: boolean;
computed_at: string;
repos: { repo: string; ref: string; total: number; recorded: number }[];
repos: { repo: string; ref: string; total: number; accounted: number }[];
largest_gaps: CoverageGap[];
// The mechanical proposer's standing (#2792): canon proposals awaiting an
// agent's confirm, and the biggest repeats-with-no-canon families.
proposed?: number;
derive_groups?: DeriveGroup[];
// The divergence readout (#2793): button B where button A is canon, and
// judged shapes whose bodies moved since they were judged.
divergent?: number;
divergence?: Divergence[];
recheck?: number;
}
interface Divergence {
path: string;
symbol: string;
kind: string;
canon_snippet_id: number;
}
const coverage = ref<Coverage | null>(null);
@@ -472,12 +498,56 @@ async function refreshCoverage() {
}
}
/* ── Forge pin (#2778) — which of the OWNER's connections serves this
project. Owner-only UI: the select lists the viewer's own keyring, which
is only the eligible set when the viewer IS the owner. ── */
const authStore = useAuthStore();
const isProjectOwner = computed(
() => !!project.value && project.value.user_id === authStore.user?.id
);
interface ForgeConnectionOption { id: number; host: string; kind: string }
const forgeOptions = ref<ForgeConnectionOption[]>([]);
const forgePin = ref<number | null>(null);
const savingForgePin = ref(false);
async function loadForgeOptions() {
if (!isProjectOwner.value) return;
try {
const res = await apiGet<{ connections: ForgeConnectionOption[] }>(
"/api/settings/forge-connections"
);
forgeOptions.value = res.connections;
forgePin.value = project.value?.forge_connection_id ?? null;
} catch {
forgeOptions.value = [];
}
}
async function saveForgePin() {
savingForgePin.value = true;
try {
await apiPut(`/api/projects/${projectId.value}/forge`, {
connection_id: forgePin.value,
});
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");
forgePin.value = project.value?.forge_connection_id ?? null;
} finally {
savingForgePin.value = false;
}
}
onMounted(async () => {
await loadProject();
loadTasks();
loadNotes();
loadDesignSystems();
loadCoverage();
loadForgeOptions();
});
/** Populate the design-system picker. Swallows failure on purpose: with no
@@ -496,6 +566,7 @@ watch(projectId, async () => {
loadTasks();
loadNotes();
loadCoverage();
loadForgeOptions();
});
watch(
@@ -665,34 +736,101 @@ async function confirmDelete() {
</div>
<template v-if="coverage">
<div class="coverage-numbers">
<span class="coverage-count">{{ coverage.recorded }}/{{ coverage.total }}</span>
<span class="coverage-label">shapes recorded</span>
<span class="coverage-count">{{ coverage.accounted }}/{{ coverage.total }}</span>
<span class="coverage-label">shapes accounted for</span>
</div>
<div
class="coverage-bar"
role="progressbar"
:aria-valuenow="coverage.recorded"
:aria-valuenow="coverage.accounted"
:aria-valuemin="0"
:aria-valuemax="coverage.total"
aria-label="Shapes with a recorded snippet"
aria-label="Shapes classified against canon"
>
<div
class="coverage-bar-fill"
:style="{ width: (coverage.total ? (coverage.recorded / coverage.total) * 100 : 0) + '%' }"
:style="{ width: (coverage.total ? (coverage.accounted / coverage.total) * 100 : 0) + '%' }"
></div>
</div>
<div v-if="coverage.counts" class="coverage-gaps">
<span
v-for="k in ['canonical', 'instance', 'variant', 'exempt', 'scoped']"
:key="k"
>
<span v-if="coverage.counts[k]" class="coverage-gap-chip">
{{ coverage.counts[k] }} {{ k }}
</span>
</span>
</div>
<div v-if="coverage.largest_gaps?.length" class="coverage-gaps">
<span class="coverage-gaps-label">Largest gaps:</span>
<span class="coverage-gaps-label">Most unclassified:</span>
<span v-for="gap in coverage.largest_gaps" :key="gap.dir" class="coverage-gap-chip">
{{ gap.dir }} <span class="coverage-gap-count">{{ gap.uncovered }}</span>
{{ gap.dir }} <span class="coverage-gap-count">{{ gap.unclassified }}</span>
</span>
</div>
<div
v-if="coverage.proposed || coverage.derive_groups?.length"
class="coverage-gaps"
title="The proposer matched these against canon; an agent confirms them in batches (confirm_shape_proposals)."
>
<span class="coverage-gaps-label">Proposed:</span>
<span v-if="coverage.proposed" class="coverage-gap-chip">
{{ coverage.proposed }} awaiting confirm
</span>
<span
v-for="g in coverage.derive_groups || []"
:key="g.group"
class="coverage-gap-chip"
:title="'Repeats with no canon — derive one first. ' + g.paths.join(', ')"
>
{{ g.label }} <span class="coverage-gap-count">×{{ g.size }}</span>
</span>
</div>
<div
v-if="coverage.divergent || coverage.recheck"
class="coverage-gaps"
title="Divergent: a new shape where one canon dominates its directory and that isn't proposed as that canon — button B where button A is canon. Recheck: a judged shape whose body changed since it was judged."
>
<span class="coverage-gaps-label">Divergence:</span>
<span
v-for="d in coverage.divergence || []"
:key="d.path + '::' + d.symbol"
class="coverage-gap-chip coverage-divergent"
:title="d.path + ' — canon here is snippet #' + d.canon_snippet_id"
>
{{ d.kind === 'css' ? '.' : '' }}{{ d.symbol }}
<span class="coverage-gap-count"> #{{ d.canon_snippet_id }}</span>
</span>
<span v-if="(coverage.divergent || 0) > (coverage.divergence?.length || 0)" class="coverage-gap-chip">
+{{ (coverage.divergent || 0) - (coverage.divergence?.length || 0) }} more
</span>
<span v-if="coverage.recheck" class="coverage-gap-chip">
{{ coverage.recheck }} to recheck
</span>
</div>
</template>
<p v-else class="coverage-empty">
Not measured yet Refresh compares the bound repo's definitions
against recorded snippets.
Not measured yet Refresh reads the bound repo's definitions into
the shape ledger and reports how many are classified against canon.
</p>
<p v-if="coverageError" class="coverage-error">{{ coverageError }}</p>
<!-- Forge pin (#2778): owner-only, because the eligible set is the
owner's own keyring. Automatic = resolve by repo host. -->
<div v-if="isProjectOwner && forgeOptions.length" class="coverage-forge-row">
<label for="forge-pin" class="coverage-gaps-label">Forge</label>
<select
id="forge-pin"
v-model="forgePin"
class="input"
:disabled="savingForgePin"
@change="saveForgePin"
>
<option :value="null">Automatic (by repo host)</option>
<option v-for="c in forgeOptions" :key="c.id" :value="c.id">
{{ c.host }} ({{ c.kind }})
</option>
</select>
</div>
</div>
<div class="project-body">
@@ -1202,6 +1340,18 @@ async function confirmDelete() {
flex-wrap: wrap;
font-size: 0.78rem;
}
.coverage-forge-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
font-size: 0.78rem;
}
.coverage-forge-row select {
max-width: 20rem;
}
.coverage-gaps-label { color: var(--fs-text-tertiary); }
.coverage-gap-chip {
padding: 0.1rem 0.5rem;
@@ -1212,6 +1362,7 @@ async function confirmDelete() {
font-size: 0.74rem;
}
.coverage-gap-count { opacity: 0.65; }
.coverage-divergent { border-color: var(--fs-warning); }
.coverage-empty {
margin: 0;
font-size: 0.8rem;
@@ -1664,31 +1815,6 @@ async function confirmDelete() {
display: flex; align-items: center; justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
.modal-message { font-size: 0.9rem; color: var(--fs-text-secondary); margin: 0 0 1.25rem; line-height: 1.5; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover { background: var(--fs-surface-page); }
.modal-btn-danger { background: var(--fs-action-destructive); border-color: var(--fs-action-destructive); color: var(--fs-text-on-action); }
.modal-btn-danger:hover { background: var(--fs-action-destructive-hover); border-color: var(--fs-action-destructive-hover); }
/* ── Skeleton ────────────────────────────────────────────────── */
@keyframes skel-shine { to { background-position: 200% center; } }
+5 -109
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { apiGet, apiPost } from "@/api/client";
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
@@ -68,12 +68,7 @@ async function handleSubmit() {
await authStore.checkAuth();
router.push("/");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Registration failed";
} else {
error.value = "Registration failed";
}
error.value = apiErrorMessage(e, "Registration failed");
} finally {
submitting.value = false;
}
@@ -85,9 +80,9 @@ async function handleSubmit() {
<div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Accept Invitation</h1></div>
<div v-if="validating" class="loading-msg">Validating invitation...</div>
<div v-if="validating" class="auth-loading">Validating invitation...</div>
<div v-else-if="!token || !valid" class="error-block">
<div v-else-if="!token || !valid" class="auth-note">
<p>This invitation link is invalid or has expired.</p>
<p class="auth-footer">
<router-link to="/login">Back to Sign In</router-link>
@@ -157,103 +152,4 @@ async function handleSubmit() {
</main>
</template>
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.loading-msg {
text-align: center;
color: var(--fs-text-tertiary);
font-size: 0.95rem;
padding: 1rem 0;
}
.error-block {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.error-block p {
margin: 0.5rem 0;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
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;
}
.input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
}
.input-error {
border-color: var(--fs-error);
}
.input-error:focus {
border-color: var(--fs-error);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
</style>
<style src="@/assets/auth-shared.css" />
+5 -104
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
import { apiErrorMessage } from "@/api/client";
const router = useRouter();
const authStore = useAuthStore();
@@ -39,12 +40,7 @@ async function handleSubmit() {
await authStore.register(username.value, password.value, email.value || undefined);
router.push("/");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Registration failed";
} else {
error.value = "Registration failed";
}
error.value = apiErrorMessage(e, "Registration failed");
} finally {
submitting.value = false;
}
@@ -56,9 +52,9 @@ async function handleSubmit() {
<div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Create Account</h1></div>
<div v-if="checking" class="loading-msg">Checking registration status...</div>
<div v-if="checking" class="auth-loading">Checking registration status...</div>
<div v-else-if="!authStore.registrationOpen" class="closed-msg">
<div v-else-if="!authStore.registrationOpen" class="auth-note">
<p>Registration is currently closed.</p>
<p>Contact an administrator to get an account.</p>
<p class="auth-footer">
@@ -130,99 +126,4 @@ async function handleSubmit() {
</main>
</template>
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.loading-msg {
text-align: center;
color: var(--fs-text-tertiary);
font-size: 0.9rem;
padding: 1rem 0;
}
.closed-msg {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.closed-msg p {
margin: 0.5rem 0;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
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;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
}
.input-error {
border-color: var(--fs-error);
}
.input-error:focus {
border-color: var(--fs-error);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
</style>
<style src="@/assets/auth-shared.css" />
+5 -108
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { useRoute } from "vue-router";
import { apiPost } from "@/api/client";
import { apiPost, apiErrorMessage } from "@/api/client";
import AppLogo from "@/components/AppLogo.vue";
const route = useRoute();
@@ -35,12 +35,7 @@ async function handleSubmit() {
});
success.value = true;
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
error.value = body?.error || "Failed to reset password";
} else {
error.value = "Failed to reset password";
}
error.value = apiErrorMessage(e, "Failed to reset password");
} finally {
submitting.value = false;
}
@@ -52,7 +47,7 @@ async function handleSubmit() {
<div class="auth-card">
<div class="auth-brand"><AppLogo :size="32" /><h1>Set New Password</h1></div>
<div v-if="!token" class="error-block">
<div v-if="!token" class="auth-note">
<p>Invalid reset link. Please request a new password reset.</p>
<p class="auth-footer">
<router-link to="/forgot-password">Request new link</router-link>
@@ -94,7 +89,7 @@ async function handleSubmit() {
</form>
</template>
<div v-else class="success-msg">
<div v-else class="auth-note">
<p>Your password has been reset successfully.</p>
<p>You can now sign in with your new password.</p>
</div>
@@ -106,102 +101,4 @@ async function handleSubmit() {
</main>
</template>
<style scoped>
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.error-block {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.error-block p {
margin: 0.5rem 0;
}
.success-msg {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.success-msg p {
margin: 0.5rem 0;
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.input {
width: 100%;
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;
}
.input:focus {
outline: none;
border-color: var(--fs-accent);
}
.input-error {
border-color: var(--fs-error);
}
.input-error:focus {
border-color: var(--fs-error);
}
.field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
}
.error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
</style>
<style src="@/assets/auth-shared.css" />
+180 -126
View File
@@ -3,10 +3,11 @@ import { ref, computed, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
const store = useSettingsStore();
const authStore = useAuthStore();
@@ -420,15 +421,25 @@ const baseUrl = ref("");
const savingBaseUrl = ref(false);
const baseUrlSaved = ref(false);
// Git forge integration (admin only, #2689). The token round-trips masked;
// the server treats the mask as "unchanged".
const forge = ref({ kind: "", base_url: "", token: "", webhook_secret: "" });
const forgeKinds = ref<string[]>(["gitea"]);
const forgeConfigured = ref(false);
const savingForge = ref(false);
const forgeSaved = ref(false);
const testingForge = ref(false);
const forgeTestResult = ref<{ ok: boolean; message: string } | null>(null);
// Git forge connections (#2778) — the user's keyring: one read-only
// credential per forge host, used server-side for every forge read on
// projects this user owns. The token round-trips masked; the server treats
// the mask as "unchanged".
interface ForgeConnectionEntry {
id: number; kind: string; base_url: string; host: string;
}
const forgeConnections = ref<ForgeConnectionEntry[]>([]);
const forgeKinds = ref<string[]>(["gitea", "github"]);
const connForm = ref({ id: 0, kind: "gitea", base_url: "", token: "" });
const connFormOpen = ref(false);
const savingConn = ref(false);
const testingConnId = ref(0);
const connTestResult = ref<{ id: number; ok: boolean; message: string } | null>(null);
// The webhook secret stays admin: the push endpoint is one URL per instance
// and authenticates deliveries, not users.
const forgeWebhookSecret = ref("");
const savingForgeWebhook = ref(false);
const forgeWebhookSaved = ref(false);
// Search test (SearXNG)
@@ -576,25 +587,30 @@ onMounted(async () => {
// base URL not configured yet
}
try {
await loadForgeSettings();
await loadForgeWebhook();
} catch {
// forge not configured yet
// webhook secret not configured yet
}
}
try {
await loadForgeConnections();
} catch {
// no keyring yet — the ordinary state
}
_loadTabContent(activeTab.value);
});
async function loadForgeSettings() {
const cfg = await apiGet<{
kind: string; base_url: string; token: string; webhook_secret: string;
configured: boolean; kinds: string[];
}>("/api/admin/forge");
forge.value = {
kind: cfg.kind, base_url: cfg.base_url, token: cfg.token,
webhook_secret: cfg.webhook_secret,
};
forgeConfigured.value = cfg.configured;
if (cfg.kinds?.length) forgeKinds.value = cfg.kinds;
async function loadForgeConnections() {
const res = await apiGet<{
connections: ForgeConnectionEntry[]; kinds: string[];
}>("/api/settings/forge-connections");
forgeConnections.value = res.connections;
if (res.kinds?.length) forgeKinds.value = res.kinds;
}
async function loadForgeWebhook() {
const cfg = await apiGet<{ webhook_secret: string }>("/api/admin/forge-webhook");
forgeWebhookSecret.value = cfg.webhook_secret;
}
async function changeEmail() {
@@ -609,12 +625,7 @@ async function changeEmail() {
emailPassword.value = "";
toastStore.show("Email updated successfully");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const b = (e as { body?: { error?: string } }).body;
toastStore.show(b?.error || "Failed to update email", "error");
} else {
toastStore.show("Failed to update email", "error");
}
toastStore.show(apiErrorMessage(e, "Failed to update email"), "error");
} finally {
changingEmail.value = false;
}
@@ -648,12 +659,7 @@ async function changePassword() {
newPassword.value = "";
confirmNewPassword.value = "";
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to change password", "error");
} else {
toastStore.show("Failed to change password", "error");
}
toastStore.show(apiErrorMessage(e, "Failed to change password"), "error");
} finally {
changingPassword.value = false;
}
@@ -751,53 +757,83 @@ async function sendTestEmail() {
await apiPost("/api/admin/smtp/test", { recipient: testRecipient.value.trim() });
toastStore.show("Test email sent successfully");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to send test email", "error");
} else {
toastStore.show("Failed to send test email", "error");
}
toastStore.show(apiErrorMessage(e, "Failed to send test email"), "error");
} finally {
sendingTest.value = false;
}
}
async function saveForge() {
savingForge.value = true;
forgeSaved.value = false;
forgeTestResult.value = null;
function editConnection(c: ForgeConnectionEntry | null) {
connTestResult.value = null;
connFormOpen.value = true;
connForm.value = c
? { id: c.id, kind: c.kind, base_url: c.base_url, token: "********" }
: { id: 0, kind: forgeKinds.value[0] || "gitea", base_url: "", token: "" };
}
async function saveConnection() {
savingConn.value = true;
try {
await apiPut("/api/admin/forge", forge.value);
await loadForgeSettings();
forgeSaved.value = true;
setTimeout(() => (forgeSaved.value = false), 2000);
const { id, ...values } = connForm.value;
if (id) await apiPut(`/api/settings/forge-connections/${id}`, values);
else await apiPost("/api/settings/forge-connections", values);
connFormOpen.value = false;
await loadForgeConnections();
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to save forge settings", "error");
toastStore.show(body?.error || "Failed to save forge connection", "error");
} finally {
savingForge.value = false;
savingConn.value = false;
}
}
async function testForge() {
testingForge.value = true;
forgeTestResult.value = null;
async function removeConnection(id: number) {
try {
await apiDelete(`/api/settings/forge-connections/${id}`);
connTestResult.value = null;
await loadForgeConnections();
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to delete forge connection", "error");
}
}
async function testConnection(id: number) {
testingConnId.value = id;
connTestResult.value = null;
try {
const res = await apiPost<{ version: string; username: string }>(
"/api/admin/forge/test", {},
`/api/settings/forge-connections/${id}/test`, {},
);
forgeTestResult.value = {
ok: true,
message: `Connected — Gitea ${res.version}, authenticated as ${res.username}`,
connTestResult.value = {
id, ok: true,
message: `Connected — ${res.version}, authenticated as ${res.username}`,
};
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
forgeTestResult.value = {
ok: false,
connTestResult.value = {
id, ok: false,
message: body?.error || "Connection test failed",
};
} finally {
testingForge.value = false;
testingConnId.value = 0;
}
}
async function saveForgeWebhook() {
savingForgeWebhook.value = true;
try {
await apiPut("/api/admin/forge-webhook", {
webhook_secret: forgeWebhookSecret.value,
});
await loadForgeWebhook();
forgeWebhookSaved.value = true;
setTimeout(() => (forgeWebhookSaved.value = false), 2000);
} catch (e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to save webhook secret", "error");
} finally {
savingForgeWebhook.value = false;
}
}
@@ -1079,14 +1115,6 @@ function toggleLogExpand(id: number) {
expandedLogId.value = expandedLogId.value === id ? null : id;
}
function formatLogTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
}
function formatLogDetails(details: string | null): string {
if (!details) return "";
try { return JSON.stringify(JSON.parse(details), null, 2); } catch { return details; }
@@ -1174,12 +1202,6 @@ async function deleteUser(userId: number) {
deleting.value = null;
}
}
function formatUserDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric", month: "short", day: "numeric",
});
}
</script>
<template>
@@ -1316,7 +1338,10 @@ function formatUserDate(iso: string): string {
Checks the file Claude is about to write or edit against your recorded
snippets — what's already kept at that path, and what resembles the code
being written so a helper you already have is offered before it's
rewritten. Uses the ceiling above with its own threshold below, and never
rewritten. When the file being edited is itself a recorded snippet's
location, the hint instead asks Claude to update or re-verify that
record as part of the edit how records stay current without any forge
connection. Uses the ceiling above with its own threshold below, and never
blocks the edit. Off = prior art surfaces only on your own prompts.
</p>
</div>
@@ -1707,6 +1732,70 @@ function formatUserDate(iso: string): string {
</template>
</section>
<section class="settings-section full-width">
<h2>Git Forges</h2>
<p class="section-desc">
Read-only connections to your git forges, one per host. Projects you
own use them server-side — resolved by repo host — to fetch and
drift-check recorded snippets and measure pattern coverage. A
read-scope token is enough. Gitea: an access token with read scope
on repositories. GitHub: a fine-grained PAT with Contents:
Read-only, base URL <code>https://github.com</code> (or your GitHub
Enterprise URL).
</p>
<table v-if="forgeConnections.length" class="api-keys-table">
<thead>
<tr><th>Host</th><th>Kind</th><th>Base URL</th><th></th></tr>
</thead>
<tbody>
<tr v-for="c in forgeConnections" :key="c.id">
<td>{{ c.host }}</td>
<td>{{ c.kind }}</td>
<td><code>{{ c.base_url }}</code></td>
<td>
<button class="btn btn-secondary btn-sm" :disabled="testingConnId === c.id" @click="testConnection(c.id)">
{{ testingConnId === c.id ? "Testing…" : "Test" }}
</button>
<button class="btn btn-secondary btn-sm" @click="editConnection(c)">Edit</button>
<button class="btn btn-danger btn-sm" @click="removeConnection(c.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="section-desc not-configured">No forge connections yet.</p>
<p
v-if="connTestResult"
:class="connTestResult.ok ? 'text-success' : 'text-error'"
>
{{ connTestResult.message }}
</p>
<div v-if="connFormOpen" class="smtp-grid">
<div class="field">
<label for="conn-kind">Forge</label>
<select id="conn-kind" v-model="connForm.kind" class="input">
<option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
</select>
</div>
<div class="field">
<label for="conn-base-url">Base URL</label>
<input id="conn-base-url" v-model="connForm.base_url" type="text" placeholder="https://git.example.com" class="input" />
</div>
<div class="field">
<label for="conn-token">API Token (read scope)</label>
<input id="conn-token" v-model="connForm.token" type="password" class="input" />
</div>
</div>
<div class="actions">
<template v-if="connFormOpen">
<button class="btn-primary" @click="saveConnection" :disabled="savingConn">
{{ savingConn ? "Saving..." : connForm.id ? "Save Connection" : "Add Connection" }}
</button>
<button class="btn-ghost" @click="connFormOpen = false">Cancel</button>
</template>
<button v-else class="btn-primary" @click="editConnection(null)">Add Connection</button>
</div>
</section>
</div>
<!-- ── Data ── -->
@@ -2158,61 +2247,26 @@ function formatUserDate(iso: string): string {
</section>
<section class="settings-section full-width">
<h2>Git Forge</h2>
<h2>Forge Webhook</h2>
<p class="section-desc">
Optional read-only connection to your git forge (Gitea or GitHub) so
snippet code can be fetched, drift-checked, and coverage-measured
server-side. A read-scope token is enough. Leave the kind unset to
keep the integration off.
Forge connections are per-user (Settings Integrations Git
Forges). What stays instance-wide is the push webhook: create one on
the forge pointing at <code>/api/webhooks/forge</code> with this
secret, and snippets whose recorded files change get flagged for
re-verification.
</p>
<div class="smtp-grid">
<div class="field">
<label for="forge-kind">Forge</label>
<select id="forge-kind" v-model="forge.kind" class="input">
<option value="">Off</option>
<option v-for="k in forgeKinds" :key="k" :value="k">{{ k }}</option>
</select>
</div>
<div class="field">
<label for="forge-base-url">Base URL</label>
<input id="forge-base-url" v-model="forge.base_url" type="text" placeholder="https://git.example.com" class="input" />
</div>
<div class="field">
<label for="forge-token">API Token (read scope)</label>
<input id="forge-token" v-model="forge.token" type="password" class="input" />
<p class="field-hint">
Gitea: an access token with read scope on repositories. GitHub:
a fine-grained PAT with Contents: Read-only (or a classic token
with repo read). For GitHub, use
<code>https://github.com</code> as the base URL — or your
GitHub Enterprise instance's URL.
</p>
</div>
<div class="field">
<label for="forge-webhook-secret">Webhook Secret</label>
<input id="forge-webhook-secret" v-model="forge.webhook_secret" type="password" class="input" />
<p class="field-hint">
Optional: create a push webhook on the forge pointing at
<code>/api/webhooks/forge</code> with this secret, and snippets
whose recorded files change get flagged for re-verification.
</p>
<input id="forge-webhook-secret" v-model="forgeWebhookSecret" type="password" class="input" />
</div>
</div>
<div class="actions" style="margin-bottom: 1.25rem;">
<button class="btn-primary" @click="saveForge" :disabled="savingForge">
{{ savingForge ? "Saving..." : "Save Forge Settings" }}
<div class="actions">
<button class="btn-primary" @click="saveForgeWebhook" :disabled="savingForgeWebhook">
{{ savingForgeWebhook ? "Saving..." : "Save Webhook Secret" }}
</button>
<button class="btn-ghost" @click="testForge" :disabled="testingForge || !forgeConfigured">
{{ testingForge ? "Testing..." : "Test Connection" }}
</button>
<span v-if="forgeSaved" class="saved-msg">Saved!</span>
<span v-if="forgeWebhookSaved" class="saved-msg">Saved!</span>
</div>
<p
v-if="forgeTestResult"
:class="forgeTestResult.ok ? 'text-success' : 'text-error'"
>
{{ forgeTestResult.message }}
</p>
</section>
</div>
@@ -2273,8 +2327,8 @@ function formatUserDate(iso: string): string {
<tbody>
<tr v-for="inv in invitations" :key="inv.id">
<td class="cell-email">{{ inv.email }}</td>
<td class="hide-mobile cell-date">{{ formatUserDate(inv.created_at) }}</td>
<td class="hide-mobile cell-date">{{ formatUserDate(inv.expires_at) }}</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" }}
@@ -2309,7 +2363,7 @@ function formatUserDate(iso: string): string {
{{ u.role }}
</span>
</td>
<td class="hide-mobile cell-date">{{ formatUserDate(u.created_at) }}</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>
@@ -2392,7 +2446,7 @@ function formatUserDate(iso: string): string {
<tbody>
<template v-for="entry in logs" :key="entry.id">
<tr class="log-row" :class="{ 'row-expanded': expandedLogId === entry.id }" @click="toggleLogExpand(entry.id)">
<td class="cell-time">{{ formatLogTime(entry.created_at) }}</td>
<td class="cell-time">{{ fmtLogStamp(entry.created_at) }}</td>
<td>
<span class="category-badge" :class="'cat-' + entry.category">{{ entry.category }}</span>
</td>
-45
View File
@@ -903,23 +903,8 @@ function usageTitle(s: SnippetListItem): string {
}
/* Merge modal */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 460px;
box-shadow: 0 8px 32px var(--color-shadow);
display: flex;
flex-direction: column;
gap: 1rem;
@@ -966,36 +951,6 @@ function usageTitle(s: SnippetListItem): string {
color: var(--fs-text-tertiary);
flex-shrink: 0;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--fs-surface-page);
}
.modal-btn-primary {
background: var(--fs-action-primary);
border-color: var(--fs-action-primary);
color: var(--fs-text-on-action);
}
.modal-btn-primary:hover:not(:disabled) {
background: var(--fs-action-primary-hover);
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
@media (max-width: 600px) {
.snippets-grid {
+7 -24
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
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;
@@ -69,12 +70,7 @@ async function sendInvite() {
inviteEmail.value = "";
await fetchInvitations();
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to send invitation", "error");
} else {
toastStore.show("Failed to send invitation", "error");
}
toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
} finally {
sendingInvite.value = false;
}
@@ -128,24 +124,11 @@ async function deleteUser(userId: number) {
users.value = users.value.filter((u) => u.id !== userId);
toastStore.show("User deleted");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to delete user", "error");
} else {
toastStore.show("Failed to delete user", "error");
}
toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
} finally {
deleting.value = null;
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
</script>
<template>
@@ -212,8 +195,8 @@ function formatDate(iso: string): string {
<tbody>
<tr v-for="inv in invitations" :key="inv.id">
<td class="cell-email">{{ inv.email }}</td>
<td class="hide-mobile cell-date">{{ formatDate(inv.created_at) }}</td>
<td class="hide-mobile cell-date">{{ formatDate(inv.expires_at) }}</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"
@@ -255,7 +238,7 @@ function formatDate(iso: string): string {
{{ u.role }}
</span>
</td>
<td class="hide-mobile cell-date">{{ formatDate(u.created_at) }}</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>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.30",
"version": "0.1.37",
"author": { "name": "Bryan Van Deusen" },
"mcpServers": {
"scribe": {
+3
View File
@@ -50,6 +50,9 @@ On install you'll be asked for:
(`hooks/scribe_prior_art.sh`) → `GET /api/plugin/prior-art`. Returns
`additionalContext` with **no** permission decision, so it can inform the write
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**.
- `skills/` → the universal process-skills, surfaced by description match.
- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync`
+85 -9
View File
@@ -8,6 +8,18 @@
# that path or in its directory, plus snippets resembling the code about to be
# written. Titles + ids only, never bodies.
#
# The answer comes in two framings (#2708). A snippet recorded AT the exact
# file being edited is the SYNC class — "you are editing the recorded file;
# updating the record is part of the edit" — which is how records stay current
# on an instance with no forge connection (decision #2707). Everything else is
# the REUSE menu. The two dedup separately (see the state files below).
#
# It is also the shape ledger's write-path feed (#2791): it names the
# definitions being written (`shapes=`), and the server — only when the
# session has PULLED a snippet this code references or resembles — records
# them as instance rows, classified_by=hook. Evidence, not judgment; the
# context line says what landed so a wrong stamp is corrected in the moment.
#
# NEVER BLOCKS. It returns `additionalContext` with no `permissionDecision`, so
# the write proceeds untouched and Claude sees the note beside the tool result.
# Any failure — unconfigured, unreachable, malformed — exits 0 in silence. A
@@ -90,10 +102,14 @@ fi
# `ReturnType name(...)`) needs a real parser, and `impl` blocks are excluded
# because several per type is normal Rust, not duplication.
# ---------------------------------------------------------------------------
local_lines=""
if [ -n "$repo_root" ] && [ -n "$code" ]; then
# kind<TAB>name for each thing this payload DEFINES.
names=$(printf '%s' "$code" | awk '
# 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:]]*[,{]/)) {
@@ -126,8 +142,16 @@ if [ -n "$repo_root" ] && [ -n "$code" ]; then
if (t != "") print "sym\t" t; next
}
}
' 2>/dev/null | sort -u | head -12) || names=""
' 2>/dev/null
}
names=""
if [ -n "$code" ]; then
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12) || names=""
fi
local_lines=""
if [ -n "$repo_root" ] && [ -n "$names" ]; then
while IFS=$'\t' read -r kind name; do
[ -n "${name:-}" ] || continue
case "$kind" in
@@ -150,6 +174,38 @@ if [ -n "$local_lines" ]; then
local_context="> Already defined elsewhere in this repo — check before adding another copy (\`git grep\` shown; this is a nudge, not a gate):"$'\n'"${local_lines}"
fi
# ---------------------------------------------------------------------------
# THE LEDGER FEED (#2791). The server keeps a shape ledger — every definition
# in the bound repo, classified against recorded canon — and this hook is the
# one place that sees a shape AT THE MOMENT IT IS WRITTEN. So it names the
# shapes in play: every definition in the payload, or — for an Edit that
# changes the inside of a function rather than its signature — the definition
# enclosing the edit, found by walking the target file upward from the edited
# lines. The server decides whether evidence exists (the session pulled a
# snippet this code references or resembles) and stamps instance rows; with
# no pulled canon in play, nothing is recorded. Titles only still — this sends
# names, not bodies.
# ---------------------------------------------------------------------------
shapes="$names"
if [ -z "$shapes" ] && [ -f "$file_path" ] && command -v tac >/dev/null 2>&1; then
old_first=$(printf '%s' "$event" \
| jq -r '.tool_input.old_string // .tool_input.old_str // empty' 2>/dev/null \
| grep -m1 -v '^[[:space:]]*$') || old_first=""
if [ -n "$old_first" ]; then
ln=$(grep -nF -m1 -- "$old_first" "$file_path" 2>/dev/null | cut -d: -f1) || ln=""
if [ -n "$ln" ]; then
shapes=$(head -n "$ln" "$file_path" | tac | scribe_defs | head -1) || shapes=""
fi
fi
fi
shapes_q=""
if [ -n "$shapes" ]; then
enc=$(printf '%s\n' "$shapes" \
| 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}"
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.
@@ -194,31 +250,51 @@ fi
# surface shows a given snippet at most once per session, but they don't silence
# each other: a title that flew past in a prompt menu twenty turns ago is
# exactly what should reappear at the moment the duplicate is being written.
#
# TWO channels, not one (#2708). The server answers in two classes — REUSE
# ("something similar/nearby is recorded") and SYNC ("a snippet records the
# exact file being edited — updating the record is part of the edit"). They
# dedup separately: a reuse hint shown early in the session must not suppress
# 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
idfile=""
syncfile=""
exclude_q=""
sync_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"
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
fi
# `|| true`, not `|| exit 0`: an unreachable instance must not discard a local
# finding that needed no instance to produce.
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}" 2>/dev/null) || body=""
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${shapes_q}" 2>/dev/null) || body=""
context=""
if [ -n "$body" ]; then
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context=""
# Remember what was surfaced so it isn't shown again this session.
if [ -n "$idfile" ] && [ -n "$context" ]; then
printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
# Remember what was surfaced so it isn't shown again this session — each
# class into its own channel: sync ids (snippets recording the edited file)
# to the sync file, everything else to the reuse file.
if [ -n "$context" ]; then
if [ -n "$idfile" ]; then
printf '%s' "$body" | jq -r '((.note_ids // []) - (.sync_note_ids // []))[]?' 2>/dev/null >> "$idfile" || true
fi
if [ -n "$syncfile" ]; then
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
fi
fi
fi
+26
View File
@@ -34,6 +34,9 @@ through recall/auto-inject; this skill is the active reflex around that.
to ask both at once.
- If a snippet fits, pull it in full with `get_snippet(id)` and reuse it — its
`location` points at the reference implementation. Adapt, don't re-derive.
The pull also does the accounting: the code you then write that references
or resembles it is stamped an `instance` of that canon in the shape ledger
(classified_by=hook) — reuse from memory leaves no row.
- If auto-inject already surfaced a snippet title that looks relevant, that's
your cue to `get_snippet` it rather than start from scratch.
- **Prior art offered beside a write is not noise — read it.** When Scribe notes
@@ -41,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 `[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,
`update_snippet(id, code=…)` with the new form as part of the same task; if
it doesn't, `verify_snippet(id, status="ok", commit_sha=…)` costs one call
and re-stamps the record as checked. Scribe never reads the repo — this
moment, in the session that has the context, is the only place the record
gets kept true.
## The first time a shape is built — record it
@@ -99,6 +110,21 @@ The result is a single entry that shows every place the thing is used — which
exactly the signal that it was worth consolidating. This is the cure the create
gate only hints at when it blocks a near-duplicate.
## Consumer maps are rows, never prose
Every enumerated relationship between code and canon belongs in the shape
ledger, not in a sentence. When you establish that call sites route through a
canonical helper — during an audit, a verify pass, or a consolidation —
record each consuming definition with
`classify_shapes(project_id, [{path, symbol, status: "instance", snippet_id}])`.
A deliberate departure is a `"variant"` (reason required — the why IS the
record); a judged one-off is `"exempt"` (reason required). Prose in a
verification detail cannot be sorted, queried, or diffed; rows are what make
"what uses this?" answerable forever. `list_shapes(project_id,
status="unclassified")` is the standing todo — and N same-shaped occurrences
matching no canon means derive one first (consolidate, `create_snippet`,
then classify the rest against it), never N loose classifications.
## Why this pays off
A one-off written a second time is the cost this avoids — and at project
+113
View File
@@ -0,0 +1,113 @@
---
name: shape-accounting
description: Use when a project's shape accounting needs attention — the pattern_coverage line from enter_project shows unclassified shapes or is missing on a forge-served project, the operator asks about coverage/accounting/canon, or you just proved a code-to-canon relationship (an audit enumerated call sites, a consolidation repointed consumers, a verify pass confirmed a helper's users). Triggers on "coverage", "accounted", "unclassified", "classify shapes", "what uses this", or finishing any consolidation.
---
# Shape accounting — every shape classified against canon
The snippet library records **canon** (small); the shape ledger accounts for
**every extracted definition** in a project's bound repos (total). Each ledger
row carries a status:
- `canonical` — IS a snippet's reference (the coverage sync stamps these
mechanically; you rarely set it).
- `instance` of snippet N — conforms to recorded canon. Canon in another
project counts (a family-level button shape fully accounts for a local use).
- `variant` of snippet N — a deliberate, named departure. **Reason required**
— the why IS the record.
- `exempt` — judged genuinely one-off. **Reason required.** A recorded
judgment, not silence — it stops the next pass re-litigating it.
- `scoped` — one-off **by construction**, stamped by the coverage sync
(a Vue component's scoped `<style>` rules and its `<script setup>`
functions — unreachable from any other file). Accounted for without a
judgment; still proposed against, grouped and flagged; any judgment you
make overrides it. Not the todo.
- `unclassified` — nobody has judged it yet. **This is the todo list.**
## The loop
1. **Seed / refresh** — the ledger fills from coverage computation. Entering a
project triggers a background seed automatically; when you need it current
*now* (before a classification batch, or when the line is missing on a
forge-served project), call `refresh_pattern_coverage(project_id)` — it
returns the fresh accounting line. Takes seconds; it moves repo archives.
2. **Read the todo**`list_shapes(project_id, status="unclassified")`,
optionally scoped by `path` to the directories the coverage line names as
largest. `snippet_id=N` reads a consumer map.
3. **Judge in batches** — `classify_shapes(project_id, [{path, symbol,
status, snippet_id?, reason?}])`. All-or-nothing: a bad item applies
nothing. Rows, never prose — a consumer list in a note or verification
detail cannot be sorted, queried, or diffed.
## Rows that arrive on their own
Two feeds keep the ledger current between your batches, so most shapes never
need a hand judgment:
- **The sync** stamps a snippet's own reference location `canonical`
(`classified_by: mechanical`).
- **The write path** stamps instances as you work: when you `get_snippet` a
canon and then Write/Edit code that references or resembles it, the
definitions being written land as `instance` rows (`classified_by: hook`,
the evidence in `reason`), and the prior-art hook tells you what landed
("Shape accounting: recorded at … → instance of #N"). Offered-but-unopened
snippets stamp nothing — so *pull the canon you are instantiating*; that
pull is what turns your reuse into accounting. A hook row is evidence, not
judgment: it never overrides a classification you made, and a
`classify_shapes` call overrides it.
## The machine proposes, judgment classifies
Every coverage refresh runs the **mechanical proposer** over the unclassified
rows: same symbol as a canon elsewhere → textual containment → body
references a canon → signature resemblance → semantic (capped per refresh). A hit
is a *proposal* on the row, never a classification. Work the queue in bulk:
1. `list_shapes(project_id, proposal="canon", snippet_id=N)` or
`path="dir"` — read the page; `proposal` carries snippet_id, basis, score.
2. `confirm_shape_proposals(project_id, snippet_id=N)` (or `path=`,
`basis=`) for the ones that hold — hundreds at a time; `symbol` and
`reference` proposals are near-certain, `semantic` deserves a look.
3. `classify_shapes` the rest — variant, exempt, or instance of a different
snippet. Any judgment retires the proposal.
`list_shapes(project_id, proposal="derive")` lists the **derive-first
candidates** — the same body in ≥2 places or the same name defined in ≥3
files, with no canon at all (`proposal.group` names the family; the coverage
payload's `derive_groups` ranks the biggest). That is the consolidation
queue, not a classification queue: see below.
## The derive-first rule
N same-shaped occurrences matching **no** recorded canon is never N loose
classifications — it is a consolidation candidate: derive one reference from
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.
## The divergence readout — button B where button A is canon
Three questions the ledger answers mechanically (#2793):
- **Divergence** — `list_shapes(project_id, flag="divergence")` (and the
coverage line's "N DIVERGENT"): a shape new since the previous refresh, in
a directory where one canon dominates the judged siblings, that the
proposer did not match to that canon. `diverges_from` names the canon.
Judge it: `instance` if it should be built from the canon (and rebuild
it), `variant` with the why if the departure is deliberate. The write-path
hook asks the same question in-band the moment such a shape is written.
- **History** — `shape_history(project_id, path, symbol?)`: the current rows
plus every `classified` / `vanished` / `reappeared` / `drifted` event with
its commit — "instance of #N from <date>, re-judged variant of #M because
R, vanished at C". Rows, not recollection.
- **Recheck** — `list_shapes(project_id, flag="recheck")`: judged
instances/variants whose body changed since judged. The judgment stands;
re-confirm it (classify again with the same status) or re-judge.
## What this buys
Divergence becomes mechanical: when button B appears where button A is canon,
the ledger says *unintended divergence* or *justified variant with its
reason* — nobody re-derives the history. `get_snippet` shows each snippet's
`instances` and `variants`, so "what uses this?" is answered from rows before
any contract change lands on its consumers.
+6 -5
View File
@@ -60,11 +60,12 @@ class Config:
# the MCP layer doesn't proxy web search (Claude has its own).
SEARXNG_URL: str = os.environ.get("SEARXNG_URL", "")
# Git forge integration (#2689) — optional read access to the operator's
# forge so snippet bodies can be fetched/verified server-side. Normally
# configured in Settings → Config (stored as admin settings); these env
# fallbacks exist so a deployment can keep the token in a Docker secret
# instead of the database. DB value wins when both are set.
# Git forge integration (#2689) — optional read access to a git forge so
# snippet bodies can be fetched/verified server-side. Connections are
# per-user keyring rows (#2778, Settings → Git forges); these env values
# survive as an implicit keyring entry for ADMIN users' projects only, so
# a deployment can keep the operator's token in a Docker secret instead
# of the database. A stored row for the same host wins over the env entry.
FORGE_KIND: str = os.environ.get("FORGE_KIND", "")
FORGE_BASE_URL: str = os.environ.get("FORGE_BASE_URL", "").rstrip("/")
FORGE_TOKEN: str = _read_secret("FORGE_TOKEN", "FORGE_TOKEN_FILE", "")
-14
View File
@@ -4,20 +4,6 @@ from __future__ import annotations
from scribe.services.api_keys import lookup_key
async def resolve_bearer_to_user_id(auth_header: str | None) -> int | None:
"""Parse an `Authorization: Bearer <token>` header and return the user_id.
Returns None if the header is missing, malformed, or the token is invalid
or revoked. The underlying lookup_key already updates last_used_at on hit.
"""
if not auth_header or not auth_header.startswith("Bearer "):
return None
raw_token = auth_header[len("Bearer "):].strip()
if not raw_token:
return None
api_key = await lookup_key(raw_token)
return api_key.user_id if api_key else None
async def resolve_bearer(auth_header: str | None) -> tuple[int, str] | None:
"""Resolve a Bearer token to (user_id, scope).
+51 -6
View File
@@ -1,6 +1,8 @@
"""FastMCP instance + Quart mount-point. Tools are registered in mcp/tools/."""
from __future__ import annotations
import difflib
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from quart import Quart
@@ -47,15 +49,15 @@ Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
- 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; a push that also delivered them was an
optimisation, not the bridge.
yourself 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. Saved procedures are Processes (follow verbatim). Deletes
are trash-recoverable.
create_snippet; classify shapes against canon (classify_shapes) — a
consumer map is rows, never prose. Saved procedures are Processes (follow
verbatim). Deletes are trash-recoverable.
A task is a note with status; *_note tools for notes, *_task for tasks.
A task is a note with status (*_note vs *_task tools).
Creates are duplicate-gated: a near-match BLOCKS and returns the existing
id — update it, don't force. shared:true records are another user's — a
suggestion, not the operator's settled practice.
@@ -108,6 +110,9 @@ _READ_ONLY_TOOLS = frozenset({
# Which repos map to which project. Read-only by nature; bind_repo /
# unbind_repo are the writes.
"list_repo_bindings",
# The shape ledger's todo query (#2789). Reads only — classify_shapes is
# the write, and it is deliberately NOT here.
"list_shapes", "shape_history",
})
# Read-SHAPED tools that must NOT be reachable with a read key — a getter that
@@ -165,6 +170,46 @@ def _body_calls_write_tool(body: bytes) -> bool:
return False
class StrictArgsFastMCP(FastMCP):
"""A FastMCP that REJECTS tool calls carrying undeclared arguments.
FastMCP validates arguments with a pydantic model built from the tool
signature, and pydantic's default extra-field policy is "ignore" — so a
misnamed argument simply vanishes and the tool runs with that field's
default. On the create/update tools the default is "", which turns a
plausible near-miss (`content=` for `body=`, primed by add_task_log's
`content`) into SILENT DATA LOSS: the call reports success and stores an
empty body, leaving a record search cannot see (#2709). Two notes were
persisted body-less that way before anyone noticed.
An error the caller sees once is strictly better than data half-written
forever, so the policy is applied to every tool, not just the two that
bit: nothing here knows tool semantics, only that an argument nobody
declared cannot have been meant to be dropped.
"""
async def call_tool(self, name, arguments):
try:
tool = self._tool_manager.get_tool(name)
except Exception:
tool = None # unknown tool → let upstream produce its own error
if tool is not None:
declared = set((tool.parameters or {}).get("properties", {}))
unknown = sorted(set(arguments or {}) - declared)
if unknown:
hints = []
for arg in unknown:
close = difflib.get_close_matches(arg, sorted(declared), n=1)
suggestion = f" (did you mean '{close[0]}'?)" if close else ""
hints.append(f"'{arg}'{suggestion}")
raise ValueError(
f"{name} does not accept argument(s) {', '.join(hints)}. "
f"It accepts: {', '.join(sorted(declared))}. Nothing was "
"created or changed — retry with the declared names."
)
return await super().call_tool(name, arguments)
def build_mcp_server() -> FastMCP:
"""Build the FastMCP instance with all tools registered.
@@ -185,7 +230,7 @@ def build_mcp_server() -> FastMCP:
# every request self-contained (bearer-auth only), so a post-deploy
# reconnect just works. Trade-off: no server-pushed list_changed stream,
# which we don't use — tools are re-fetched on reconnect anyway.
mcp = FastMCP(
mcp = StrictArgsFastMCP(
"scribe",
instructions=_INSTRUCTIONS.strip(),
stateless_http=True,
+3 -2
View File
@@ -5,8 +5,8 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`.
"""
from scribe.mcp.tools import (
design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, snippets,
systems, tags, tasks, trash,
design_systems, milestones, notes, processes, projects, recent, repos, rulebooks, search, shapes,
snippets, systems, tags, tasks, trash,
)
@@ -24,5 +24,6 @@ def register_all(mcp) -> None:
repos.register(mcp)
processes.register(mcp)
snippets.register(mcp)
shapes.register(mcp)
rulebooks.register(mcp)
trash.register(mcp)
+1 -3
View File
@@ -57,9 +57,7 @@ async def get_milestone(milestone_id: int) -> dict:
return {
"milestone": out,
"steps": [t.to_dict() for t in steps],
"applicable_rules": applicable["rules"],
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
**rulebooks_svc.rules_payload(applicable),
}
+3 -27
View File
@@ -62,30 +62,6 @@ async def list_notes(
return {"notes": [n.to_dict() for n in rows], "total": total}
async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
"""Add both directions of the supersession relation to a note payload.
Both, because they answer different questions and only one of them is
obvious. `supersedes` is what the author claimed. `superseded_by` is what a
READER needs and what the note itself cannot know — a stale record handed
over without that marker gets acted on confidently, which is worse than
never surfacing it.
Omitted entirely when empty, so an ordinary note's payload doesn't grow two
permanently-empty lists. A field that always says nothing trains readers to
skip fields, which is the lesson `consolidated_at` cost us (#2483).
"""
rel = await supersession_svc.get_relations(uid, note_id)
if rel["supersedes"]:
data["supersedes"] = rel["supersedes"]
if rel["superseded_by"]:
data["superseded_by"] = rel["superseded_by"]
data["superseded_note"] = (
"A later note claims to bring this up to date — see superseded_by. "
"Read this as what was true when written, and check the newer one "
"before acting on it."
)
async def get_note(note_id: int) -> dict:
"""Fetch the full content of a single Scribe note by its ID.
@@ -113,7 +89,7 @@ async def get_note(note_id: int) -> dict:
# snippets would leave those permanently at zero pulls and make them look
# like dead weight next to snippets that merely had a counter (#2085).
record_pulled(user_id=uid, note_id=int(note.id), source="mcp_get_note")
await _attach_supersession(uid, note_id, out)
await supersession_svc.attach_relations(uid, note_id, out, hint=True)
await systems_tools.attach_systems(
uid, getattr(note, "user_id", uid) or uid, out, note.id, note.project_id
)
@@ -186,7 +162,7 @@ async def create_note(
raise ValueError(str(exc)) from exc
data = note.to_dict()
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
await _attach_supersession(uid, note.id, data)
await supersession_svc.attach_relations(uid, note.id, data, hint=True)
return data
@@ -237,7 +213,7 @@ async def update_note(
await systems_tools.attach_systems(
uid, getattr(note, "user_id", uid) or uid, data, note_id, note.project_id
)
await _attach_supersession(uid, note_id, data)
await supersession_svc.attach_relations(uid, note_id, data, hint=True)
return data
+49 -19
View File
@@ -17,6 +17,7 @@ keeps working.
from __future__ import annotations
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 milestones as milestones_svc
@@ -25,6 +26,7 @@ from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
from scribe.services import trash as trash_svc
from scribe.services.background import spawn
from scribe.services.note_usage import record_surfaced
@@ -57,13 +59,19 @@ async def enter_project(project_id: int) -> dict:
Returns a dict with keys: project, milestone_summary, applicable_rules,
project_rules, subscribed_rulebooks, applicable_rules_truncated,
open_tasks, recent_notes, design_system, systems, pattern_coverage.
open_tasks, recent_notes, design_system, systems, pattern_coverage
plus systems_bootstrap, present only when it applies (see below).
`pattern_coverage` (usually null) is a one-line estimate of how much of
the bound repo's code has recorded snippets — e.g. "pattern-library
coverage: 34/210 shapes recorded (estimate); largest gaps: internal/api".
When present, treat the gaps as a standing invitation: as you touch code
in those areas, record the shapes you find with create_snippet.
`pattern_coverage` (usually null) is the shape-accounting line — how many
of the bound repo's extracted shapes carry a classification against canon
(note 2786) — e.g. "shape accounting: 3100/4573 shapes accounted for —
12 canonical · 2900 instance (estimate, computed 2026-08-19); 1473
unclassified, largest: internal/api". Unclassified IS the todo: as you
touch code in those areas, classify the shapes you can (instances of
recorded canon, deliberate variants, one-off exemptions) and record the
canon that's missing with create_snippet. A null line on a forge-served
project usually means the ledger is seeding in the background (entering
triggers it); refresh_pattern_coverage computes it on the spot.
`systems` is the project's vocabulary of named subsystems/areas. It is
returned here so you can TAG as you write: when creating or meaningfully
@@ -72,6 +80,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.
`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
and without asking permission, preferring the standard names the ask
lists. It stops appearing the moment the first System exists.
`design_system` is null unless the project points at one. When present it
carries the chain-merged guidance (the house style AND this project's
departures from it) plus a summary of the token set — treat it as binding
@@ -116,6 +130,17 @@ async def enter_project(project_id: int) -> dict:
# three days of the feature landing (#2546's audit).
systems = await systems_svc.list_systems(uid, project_id)
# The arrival-moment half of the bootstrap ask (#2683): session start is
# when the agent has just read the project map and is not yet deep in a
# task — the one moment minting a starter vocabulary is cheap. The
# write-moment half rides untagged-record responses (attach_systems);
# both retire the instant the first System exists.
systems_bootstrap = None
if not systems:
systems_bootstrap = await systems_tools.bootstrap_systems_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
@@ -142,8 +167,18 @@ async def enter_project(project_id: int) -> dict:
coverage = await coverage_svc.cached_coverage(
project.user_id or uid, project_id
)
# Arrival self-seed (#2802): a ledger that is absent or stale refreshes in
# the BACKGROUND — entering is the moment the number is wanted, and the
# UI button must not be the only path. This enter stays fast; the next
# one carries the line. Forge-less projects exit the seed quietly.
spawn(
coverage_svc.refresh_if_stale(
project.user_id or uid, project_id, cached=coverage
),
site="enter_project.coverage_seed",
)
return {
out = {
"project": project.to_dict(),
"pattern_coverage": coverage_svc.coverage_line(coverage) if coverage else None,
# Trimmed to what tagging needs. The full charter is get_system's job —
@@ -157,12 +192,7 @@ async def enter_project(project_id: int) -> dict:
],
"design_system": design_system,
"milestone_summary": milestone_summary,
"applicable_rules": applicable["rules"],
"project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
**rulebooks_svc.rules_payload(applicable),
"open_tasks": [
{
"id": t.id, "title": t.title, "status": t.status,
@@ -179,6 +209,11 @@ async def enter_project(project_id: int) -> dict:
for n in recent_notes
],
}
# Attached only when it applies — a key that usually says null trains
# readers to skip it (#2483), and this one exists to be acted on.
if systems_bootstrap:
out["systems_bootstrap"] = systems_bootstrap
return out
async def get_project(project_id: int) -> dict:
@@ -199,12 +234,7 @@ async def get_project(project_id: int) -> dict:
applicable = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=uid,
)
data["applicable_rules"] = applicable["rules"]
data["applicable_rules_truncated"] = applicable["truncated"]
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
data["project_rules"] = applicable.get("project_rules", [])
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
data.update(rulebooks_svc.rules_payload(applicable))
return data
+18 -3
View File
@@ -13,28 +13,43 @@ from scribe.services import projects as projects_svc
from scribe.services import repo_bindings as repo_bindings_svc
async def bind_repo(repo_url: str, project_id: int) -> dict:
async def bind_repo(repo_url: str, project_id: int, ref: str = "") -> dict:
"""Bind a git repository to a Scribe project for session-start context.
After this, any session started in that repo auto-loads the project's
context (the SessionStart hook sends the repo's remote; the server resolves
it here). Idempotent — re-binding the same repo updates the target project.
The binding is also what the shape ledger reads (refresh_pattern_coverage):
`ref` names the branch it follows. Default (""): the repo's default branch
— which means the ledger only sees work after a merge. A dev-first project
(rule 1: dev is home) should bind with ref="dev" so classification follows
the push, not the merge. Re-binding with ref="" keeps the standing ref;
pass ref="-" to clear it back to the default branch.
Args:
repo_url: the repo's git remote (e.g. the output of
`git remote get-url origin` — ssh or https form, both work).
project_id: the Scribe project this repo represents.
ref: branch the ledger follows ("" = leave as is / default branch on
a new binding; "-" = clear to the default branch).
"""
uid = current_user_id()
project = await projects_svc.get_project(uid, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
binding = await repo_bindings_svc.set_binding(uid, repo_url, project_id)
ref_arg = None if not ref else ("" if ref.strip() == "-" else ref)
binding = await repo_bindings_svc.set_binding(uid, repo_url, project_id, ref_arg)
follows = binding.ref or "the default branch"
return {
"repo_key": binding.repo_key,
"project_id": binding.project_id,
"project_title": project.title,
"message": f"Bound `{binding.repo_key}` -> {project.title} (id {project.id}).",
"ref": binding.ref,
"message": (
f"Bound `{binding.repo_key}` -> {project.title} (id {project.id}); "
f"the ledger follows {follows}."
),
}
+8 -20
View File
@@ -193,6 +193,12 @@ async def delete_topic(topic_id: int, confirmed: bool = False) -> dict:
# ── Rule CRUD ──────────────────────────────────────────────────────────
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}
async def list_rules(
rulebook_id: int = 0, topic_id: int = 0, project_id: int = 0,
) -> dict:
@@ -213,16 +219,7 @@ async def list_rules(
topic_id=topic_id or None,
project_id=project_id or None,
)
return {
"rules": [
{
"id": r.id, "title": r.title, "statement": r.statement,
"topic_id": r.topic_id,
}
for r in rows
],
"total": len(rows),
}
return {"rules": [_rule_summary(r) for r in rows], "total": len(rows)}
async def list_always_on_rules() -> dict:
@@ -235,16 +232,7 @@ async def list_always_on_rules() -> dict:
"""
uid = current_user_id()
rules = await rulebooks_svc.list_always_on_rules(uid)
return {
"rules": [
{
"id": r.id, "title": r.title, "statement": r.statement,
"topic_id": r.topic_id,
}
for r in rules
],
"total": len(rules),
}
return {"rules": [_rule_summary(r) for r in rules], "total": len(rules)}
async def get_rule(rule_id: int) -> dict:
+314
View File
@@ -0,0 +1,314 @@
"""Shape-ledger MCP tools — the classification write/read surface (#2789).
The accounting model (note 2786): the snippet library records CANON (small);
the ledger accounts for EVERY extracted shape (total). These tools are how
agents move shapes out of `unclassified` — the todo state — and how they read
what still needs judgment. The ledger rows themselves are fed by the coverage
refresh; these tools only ever judge what the sync has seen.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import coverage as coverage_svc
from scribe.services import shape_ledger as shape_ledger_svc
async def classify_shapes(
project_id: int, classifications: list[dict], via: str = "agent"
) -> dict:
"""Record judgments for a project's code shapes — in batch, as rows.
EVERY shape in a bound repo should end up classified (note 2786):
- `instance` of snippet N — it conforms to recorded canon (family-level
canon in another project counts; that fully accounts for the shape).
- `variant` of snippet N — a deliberate, named departure. `reason`
(the why) is REQUIRED; it is the record.
- `exempt` — judged genuinely one-off. `reason` REQUIRED.
- `canonical` of snippet N — this row IS the snippet's reference
(rarely set by hand; the coverage sync stamps these mechanically).
- `unclassified` — withdraw a judgment; the shape rejoins the todo.
Consumer maps belong HERE, not in prose: when an audit enumerates call
sites of a canonical helper, each call site's defining shape is an
`instance` row — a sentence in a verification detail cannot be sorted,
queried, or diffed.
Args:
project_id: The project whose ledger is being judged.
classifications: Objects of {path, symbol, status, kind?, snippet_id?,
reason?, reason_code?}. path+symbol name the shape exactly as
list_shapes shows it; kind ("sym"/"css") narrows when one file
defines both. snippet_id is required for canonical/instance/
variant; reason is required for variant/exempt. reason_code is
an OPTIONAL index beside the prose (one of: scoped-css,
one-off-handler, test-helper, convention-plumbing, pure-helper,
generated, script, typed-record) so the ledger can be filtered
and aggregated by kind of one-off — the prose stays the record.
uses is an OPTIONAL list of snippet ids this shape CALLS (#2870):
conformance (status + snippet_id) says what shape it is, uses
says which canonical helpers it consumes — a service function
can be an instance of the service-function convention AND use
hash_token. Consumer maps are uses edges; list_shapes(uses=N)
and get_snippet's `uses` read them.
via: Who is judging — "agent" (default), "audit" (a sweep), or
"import" (carrying maps recorded elsewhere).
All-or-nothing: a structural error, a missing snippet target, or no write
access applies NOTHING. Returns {"classified": N, "unmatched": [...]} —
unmatched names shapes no live ledger row matches (the tree may have
moved since you listed; re-run the project's coverage refresh to re-sync).
"""
uid = current_user_id()
return await shape_ledger_svc.classify_shapes(
uid, project_id, classifications, via=via
)
async def list_shapes(
project_id: int,
status: str = "",
path: str = "",
snippet_id: int = 0,
include_vanished: bool = False,
limit: int = 100,
offset: int = 0,
proposal: str = "",
flag: str = "",
compact: bool = False,
uses: int = 0,
) -> dict:
"""Read a project's shape ledger — `status="unclassified"` IS the todo.
Every extracted definition in the project's bound repos has a row here
(fed by the coverage refresh). Filters compose:
Args:
status: canonical | instance | variant | exempt | scoped | unclassified.
`scoped` (#2869) is the sync's mechanical stamp on one-offs by
construction (a Vue component's scoped <style> rules and its
<script setup> functions): accounted for, not judged, still
proposed against / grouped / flagged, and overridable by any
classify_shapes judgment. The human todo is `unclassified`.
path: exact file, or a directory — matches everything beneath it
(the coverage line's "largest" dirs go straight in here).
snippet_id: rows classified against this snippet (instance/variant
of it — conformance).
uses: rows that CALL this snippet (#2870) — the consumer map proper,
whatever shape each row is itself; edges come from judgments
(classify_shapes uses=), the write-path hook, and the proposer's
by-name reference hits.
include_vanished: include shapes no longer in the tree (history).
limit/offset: page through big ledgers (limit caps at 500).
compact: rows as `path · symbol · kind · status · signature` plus
snippet_id / by / proposal / diverges_from / recheck only when
set — no commits, shas or timestamps. THE form for an audit:
a full 500-row page fits the tool budget. The default rows carry
everything (shape_history-grade bookkeeping).
proposal: the proposer's queue (#2792) — "any", "canon" (rows the
machine thinks are an instance of a snippet: `proposal` carries
snippet_id, basis, score), "derive" (rows that repeat with NO
canon: `proposal.group` names the family), or one basis
(symbol/text/reference/signature/semantic).
flag: the divergence readout (#2793) — "divergence": shapes new
since the previous refresh in a directory where one canon
dominates the judged siblings and NOT proposed as that canon
(`diverges_from` names it: button B where button A is canon —
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).
Returns {"shapes": [...], "total": N} — total counts every match, not
just this page. 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
was stamped an instance with the evidence in `reason`. A hook row is
overridable by any classify_shapes call; it never overrides yours.
THE FAST PATH through a big todo is the proposer's queue: every coverage
refresh matches unclassified shapes against canon (strongest basis
first: same symbol elsewhere → textual containment → body references
the canon → signature resemblance → semantic) and attaches a
`proposal` to each row it can speak for. Review `proposal="canon"` by
snippet or directory, then confirm_shape_proposals the ones that hold —
hundreds at a time — and classify_shapes the rest (variant/exempt, or
instance of a different snippet). `proposal="derive"` lists the
derive-first candidates: a repeating shape with NO recorded canon is
never N loose classifications — consolidate onto a reference,
create_snippet it, then classify the group against it.
"""
uid = current_user_id()
rows, total = await shape_ledger_svc.list_project_shapes(
uid, project_id,
status=status, path=path, snippet_id=snippet_id,
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,
}
async def classify_shapes_by_rule(
project_id: int,
path: str,
status: str,
pattern: str = "",
kind: str = "",
snippet_id: int = 0,
reason: str = "",
via: str = "agent",
include_judged: bool = False,
reason_code: str = "",
uses: list[int] | None = None,
) -> dict:
"""The sweep form of classify_shapes: ONE judgment applied to every
unclassified shape under a directory whose symbol matches a glob.
For the long tail an audit judges by family, not by row — "every scoped
rule under frontend/src/views is exempt: styles one element of its view",
"every `*_scheduler.py` symbol is an instance of ScheduledJob" — where
listing 900 rows and sending them back is the whole cost. The row form
stays the precise tool; reach for it when each row gets its own reason.
Args:
project_id: The project whose ledger is being judged.
path: A file, or a directory and everything beneath it. Required —
a sweep names what it judges.
status: instance | variant | exempt | unclassified (canonical is the
sync's stamp, not a sweep's).
pattern: Shell glob on the symbol (`*_rows`, `_*`, `modal-*`, `*`);
"" = every symbol under path.
kind: "sym" or "css" to narrow; "" = both.
snippet_id: Required for instance/variant — the canon judged against.
reason: Required for variant/exempt — the why, recorded on every row.
via: "agent" (default) | "audit" | "import".
reason_code: Optional catalogue code beside the reason (see
classify_shapes) — a sweep is exactly where one applies.
uses: Optional snippet ids every matched shape CALLS (#2870) — e.g.
"every *_scheduler.py symbol uses ScheduledJob".
include_judged: By default only unjudged rows are touched —
`unclassified` and the sync's mechanical `scoped` stamp — a
sweep never silently overwrites a judgment. True re-judges every
matching live row (use to re-confirm after a recheck, or to
revise a family you judged earlier).
One transaction: applies whole or not at all. Returns
{"classified": N, "sample": ["path::symbol", ...]} (first 12, sorted)
so you can see what the rule reached; N = 0 means the rule matched
nothing live and unclassified — widen the pattern or refresh coverage.
"""
uid = current_user_id()
try:
return await shape_ledger_svc.classify_shapes_where(
uid, project_id, path=path, status=status, pattern=pattern,
kind=kind, snippet_id=snippet_id or None, reason=reason or None,
via=via, include_judged=include_judged,
reason_code=reason_code or None, uses=uses or None,
)
except ValueError as exc:
return {"error": str(exc)}
async def shape_history(
project_id: int, path: str, symbol: str = "", limit: int = 200
) -> dict:
"""What was used here, when, and why — a shape's (or a directory's)
history from the ledger (#2793).
`shapes` are the current rows at `path` (a file, or a directory and
everything beneath it; `symbol` narrows to one definition) with
first/last-seen commits, vanished_at, and the standing judgment;
`events` are the state changes, oldest first: `classified` (status,
snippet_id, who, why — one per judgment, so a shape that was an instance
of #N and later a variant of #M shows both), `vanished`, `reappeared`,
`drifted` (the body moved under a judgment; see list_shapes flag=
"recheck"). Each event carries the commit the tree was read at.
Read it as a timeline: "instance of #N from <first classified at>,
re-judged variant of #M at <at> because <reason>, vanished at <commit>".
Read-only; requires read access to the project.
"""
uid = current_user_id()
return await shape_ledger_svc.shape_history(
uid, project_id, path, symbol=symbol, limit=limit
)
async def confirm_shape_proposals(
project_id: int,
snippet_id: int = 0,
path: str = "",
basis: str = "",
min_score: float = 0.0,
) -> dict:
"""Confirm the proposer's canon proposals you have reviewed, in batch.
The machine proposes, judgment classifies (#2792): each matching row —
live, unclassified, carrying a `proposal` with a snippet_id — becomes
`instance` of that snippet, classified_by="agent", reason naming the
basis and score. Narrow to what you actually looked at: at least one of
snippet_id (confirm one canon's whole queue after reading its
`list_shapes(proposal="canon", ...)` page), path (a directory you
audited), or basis (e.g. "symbol" and "reference" are near-certain;
"semantic" deserves a look first) is required — a bare confirm-all is
not a judgment. min_score trims a basis's tail.
Proposals you do NOT confirm are judged with classify_shapes (variant,
exempt, or instance of a different snippet) — any judgment retires the
proposal. Requires write access. Returns {"confirmed": N}.
"""
uid = current_user_id()
try:
return await shape_ledger_svc.confirm_proposals(
uid, project_id, snippet_id=snippet_id, path=path, basis=basis,
min_score=min_score,
)
except ValueError as exc:
return {"error": str(exc)}
async def refresh_pattern_coverage(project_id: int) -> dict:
"""Seed or refresh the project's shape ledger NOW, and return the readout.
The synchronous form of the arrival-moment background seed (#2802):
downloads the bound repos through the OWNER's forge connections, upserts
every extracted shape into the ledger (new shapes arrive `unclassified`),
re-stamps snippet reference locations as canonical, and recomputes the
accounting. Reach for it when the ledger must be current before you act —
a classification batch about to run, a coverage question asked directly —
rather than waiting on the background seed an enter_project triggers.
Takes seconds, not milliseconds (it moves repo archives). Requires write
access to the project. Errors name the fix: no forge connection → the
owner adds one (Settings → Integrations → Git Forges); no served repo →
bind_repo on a host a connection serves.
The refresh is also when the mechanical proposer runs (#2792): with the
repo bodies in hand it matches every changed unclassified shape against
canon and records proposals (see list_shapes proposal=), then regroups
the derive-first candidates. Semantic matching is capped per refresh, so
a large ledger's queue grows across refreshes rather than in one.
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
`pattern_coverage`, the same one-line summary enter_project carries.
"""
uid = current_user_id()
coverage = await coverage_svc.refresh_for_caller(uid, project_id)
return {
"pattern_coverage": coverage_svc.coverage_line(coverage),
**coverage,
}
def register(mcp) -> None:
for fn in (
classify_shapes, classify_shapes_by_rule, list_shapes,
refresh_pattern_coverage, confirm_shape_proposals, shape_history,
):
mcp.tool(name=fn.__name__)(fn)
+27
View File
@@ -207,6 +207,12 @@ 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.
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
its why). Consult them before changing the snippet's contract: they are
the call sites your change lands on (classify_shapes maintains them).
If the record belongs to someone else it carries `shared: true` with the
`owner` and your `permission`. Read that as ONE PERSON'S SUGGESTION, not as
established practice here: judge it on its merits, say whose it is when you
@@ -229,6 +235,20 @@ async def get_snippet(snippet_id: int) -> dict:
await systems_tools.attach_systems(
uid, note.user_id, data, note.id, note.project_id
)
# The structured consumer map (#2789): ledger rows judged against this
# snippet. Attached only when non-empty (#2483) — and never for projects
# the caller can't read.
from scribe.services import shape_ledger as shape_ledger_svc
consumers = await shape_ledger_svc.snippet_consumers(uid, int(note.id))
if consumers["instances"]:
data["instances"] = consumers["instances"]
if consumers["variants"]:
data["variants"] = consumers["variants"]
if consumers.get("uses"):
# The call sites (#2870): shapes that use this snippet, whatever
# shape they are themselves.
data["uses"] = consumers["uses"]
return data
@@ -336,6 +356,13 @@ async def verify_snippet(
"moved to services/knowledge.py"). It's what makes the record fixable later
by someone who wasn't here, so write it for them, not as a status echo.
CONSUMERS you enumerate while checking ("all N call sites still route
through it") are ledger rows, not detail prose: classify each consuming
definition as an `instance` of this snippet with classify_shapes — prose
cannot be sorted, queried, or diffed (note 2786's lesson), and the rows
are what make "what uses this?" answerable forever. `detail` keeps the
WHY and what changed, nothing that belongs in a row.
A verdict expires automatically if the snippet is edited afterwards: it is
stamped with a hash of the code it was checked against, so it can never go
on vouching for code nobody checked. Re-verify after fixing a record.
+84 -6
View File
@@ -13,8 +13,74 @@ Sentinels (match the milestone/task tool conventions):
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import notes as notes_svc
from scribe.services import systems as systems_svc
# Below this, a project is young enough that the mild "which area is this
# about?" question stays proportionate; at or above it, a zero-Systems project
# has demonstrated that the question never converts (#2683 — Minstrel reached
# 282 records without a single System) and the ask escalates.
_BOOTSTRAP_MIN_RECORDS = 20
_BOOTSTRAP_TITLES = 6
# The standard vocabulary (#2798): area names that recur across software
# projects, offered so "CI & Release" means the same thing in every project
# on the instance. Consistency comes from the shared names — NOT from asking
# the operator to approve each System; agents mint directly. Generic by
# 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",
)
async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
"""The escalated vocabulary-bootstrap ask for a mature zero-Systems project.
The generic zero-state question habituates: identical on every record,
maximal in scope ("invent the taxonomy"), asked at wrap-up time — so
organic sessions skip it forever and only audit-shaped sessions ever mint
(#2683). What separates the nudges that convert from the prose that
doesn't (the duplicate gate, the prior-art "already defined in 2 files")
is the project's OWN evidence in the ask — so this one carries the record
count and the recent titles, and asks for a concrete deliverable: create
a starter set directly, preferring the standard cross-project names.
Deliberately NOT an approval flow (#2798): the operator is not a
permission gate for vocabulary; the standard names carry consistency.
Self-retiring by construction: callers only reach for it while the
project has zero Systems, so the first create_system ends it everywhere.
Returns None below the record threshold or on any failure (fail-open —
a hint must never break the call it rides on).
"""
try:
recent, total = await notes_svc.list_notes(
user_id, project_id=project_id, sort="updated_at",
limit=_BOOTSTRAP_TITLES,
)
except Exception:
return None
if total < _BOOTSTRAP_MIN_RECORDS:
return None
titles = "; ".join(
'"' + " ".join((n.title or "").split())[:70] + '"' for n in recent
)
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 "
"invisible. Bootstrap the vocabulary now, in this session, without "
"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 "
"System exists; answering it once retires it for every future record."
)
async def untagged_systems_hint(user_id: int, project_id: int) -> str | None:
"""The Systems question, for an untagged project record.
@@ -26,6 +92,10 @@ async def untagged_systems_hint(user_id: int, project_id: int) -> str | None:
vocabulary is not an exemption — it is the question at its most urgent
(#2562, #2569). Instruction prose alone demonstrably doesn't fire at write
time; in-band behavior (the duplicate gate) does.
In a MATURE zero-Systems project the question escalates to the bootstrap
ask instead (#2683): the mild form demonstrably never converts there, and
an evidence-carrying, deliverable-shaped ask is the form that does.
"""
# Fail-open like the dedup gate: a hint must never break the call.
try:
@@ -37,6 +107,9 @@ async def untagged_systems_hint(user_id: int, project_id: int) -> str | None:
f"#{s.id} {s.name}" for s in systems
) + "."
else:
ask = await bootstrap_systems_ask(user_id, project_id)
if ask:
return ask
vocab = "This project has no Systems yet."
return (
"This record is untagged — which area(s) of the project is it about? "
@@ -61,7 +134,7 @@ async def attach_systems(
tagged record shows its areas (the touching-a-System reflex needs the
affiliation visible on read, not just settable on write), an untagged
project record carries the question instead. Neither field is ever
attached empty (same reasoning as notes._attach_supersession / #2483 — a
attached empty (same reasoning as supersession_svc.attach_relations / #2483 — a
field that always says nothing trains readers to skip fields). The hint
goes only to the record's owner: tagging someone else's record in someone
else's project is not the caller's call to make. Fail-open — decoration
@@ -92,11 +165,16 @@ async def create_system(
Create one the moment two records would share an area that has no System
yet — the same two-or-more test snippets use. Don't wait to be asked to
name an area that plainly exists in the code; an unmodelled area means
every record about it stays untaggable. An audit or sweep that walks the
codebase is a DISCOVERY moment: mint the Systems it names as it names
them — the duplicate gate below, plus reviewing the existing list, is what
guards against sprawl, not holding back. Give each one a one-paragraph
name an area that plainly exists in the code, and don't route the
creation through operator approval — minting vocabulary is the agent's
call (#2798); an unmodelled area means every record about it stays
untaggable. An audit or sweep that walks the codebase is a DISCOVERY
moment: mint the Systems it names as it names them — the duplicate gate
below, plus reviewing the existing list, is what guards against sprawl,
not holding back. Prefer the standard cross-project names where the area
fits one (CI & Release, Auth & Access, Data Model & Storage, API Surface,
UI & Design, Import & Export, Background Jobs, Observability) so the same
word means the same thing in every project. Give each one a one-paragraph
charter, not just a label: the description is what tells a later session
whether a record belongs here.
+1 -6
View File
@@ -97,12 +97,7 @@ async def get_task(task_id: int) -> dict:
applicable = await rulebooks_svc.get_applicable_rules(
project_id=note.project_id, user_id=uid,
)
data["applicable_rules"] = applicable["rules"]
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
data["applicable_rules_truncated"] = applicable["truncated"]
data["project_rules"] = applicable.get("project_rules", [])
data["suppressed_rules"] = applicable.get("suppressed_rules", [])
data["suppressed_topics"] = applicable.get("suppressed_topics", [])
data.update(rulebooks_svc.rules_payload(applicable))
data.update(await access_svc.describe_provenance(uid, note))
# Same reasoning as get_note's record_pulled, and this is the tool where it
# matters MOST: auto-inject ranks kind-blind over a corpus that is
+2
View File
@@ -43,5 +43,7 @@ from scribe.models.rulebook import ( # noqa: E402, F401
Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions,
)
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.system import System, RecordSystem # noqa: E402, F401
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
+4 -4
View File
@@ -4,7 +4,7 @@ from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin
from scribe.models.base import CreatedAtMixin, iso
class ApiKey(Base, CreatedAtMixin):
@@ -36,7 +36,7 @@ class ApiKey(Base, CreatedAtMixin):
"name": self.name,
"key_prefix": self.key_prefix,
"scope": self.scope,
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
"created_at": self.created_at.isoformat(),
"revoked_at": self.revoked_at.isoformat() if self.revoked_at else None,
"last_used_at": iso(self.last_used_at),
"created_at": iso(self.created_at),
"revoked_at": iso(self.revoked_at),
}
+5 -1
View File
@@ -4,6 +4,7 @@ from sqlalchemy import DateTime, Float, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import iso
class AppLog(Base):
@@ -20,6 +21,9 @@ class AppLog(Base):
duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
ip_address: Mapped[str | None] = mapped_column(Text, nullable=True)
details: Mapped[str | None] = mapped_column(Text, nullable=True)
# Declared here rather than via CreatedAtMixin on purpose: the composite
# index below orders on `created_at.desc()`, which needs the column object
# in this class body — a mixin's column is not in scope there.
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
@@ -44,5 +48,5 @@ class AppLog(Base):
"duration_ms": self.duration_ms,
"ip_address": self.ip_address,
"details": self.details,
"created_at": self.created_at.isoformat() if self.created_at else None,
"created_at": iso(self.created_at),
}
+11 -1
View File
@@ -1,9 +1,19 @@
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from sqlalchemy import DateTime, Text
from sqlalchemy.orm import Mapped, mapped_column
def iso(value: datetime | date | None) -> str | None:
"""ISO-8601 for a payload, None for an unset column.
Every model's to_dict serialises timestamps through this one helper so a
row read before flush (created_at still None) and a nullable column both
come out as null instead of raising on `.isoformat()`.
"""
return value.isoformat() if value else None
class SoftDeleteMixin:
"""Recoverable-delete columns. NULL deleted_at = live row. deleted_batch_id
groups rows soft-deleted in one operation so a cascade restores as a unit."""
+322
View File
@@ -0,0 +1,322 @@
from datetime import datetime, timezone
from sqlalchemy import (
BigInteger,
DateTime,
Float,
ForeignKey,
Index,
Integer,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin, iso
# The classification vocabulary (note 2786). `unclassified` is the default and
# THE todo state; every other status is a judgment, stamped with who made it —
# except `scoped` (#2869): the coverage sync's mechanical stamp on shapes that
# are one-offs BY CONSTRUCTION (a Vue component's scoped <style> rules and its
# <script setup> functions — unreachable from any other file). Scoped rows are
# accounted for without a human judging them, so `exempt` keeps meaning "a
# person looked"; the proposer, derive grouping and divergence still see them,
# and any judgment (instance/variant/exempt) overrides the stamp.
SHAPE_STATUSES = ("canonical", "instance", "variant", "exempt", "scoped", "unclassified")
SHAPE_CLASSIFIERS = ("agent", "audit", "hook", "mechanical", "import")
# The reason catalogue (#2874): an OPTIONAL code beside the prose reason on
# variant/exempt rows, so the ledger can be filtered and aggregated by kind
# of one-off. The prose remains the record; the code is the index.
REASON_CODES = (
"scoped-css", # a scoped rule styling one element (pre-#2869 rows)
"one-off-handler", # a view/component handler or loader, one per surface
"test-helper", # a test module's stub, driver or fixture data
"convention-plumbing", # registration, wiring, app factory — one of each
"pure-helper", # a sync module-private helper with no session
"generated", # generated source (theme.css, protos, bundles)
"script", # a standalone dev/CI script
"typed-record", # a NamedTuple / dataclass / error class — one each
)
# How the mechanical proposer (#2792) arrived at a proposal, strongest first.
# `derive` is the odd one out: not "this is an instance of #N" but "this
# shape repeats with NO canon — derive one first" (note 2786's derive-first
# rule), so it carries a group key instead of a snippet.
PROPOSAL_BASES = ("symbol", "text", "reference", "signature", "semantic", "derive")
class CodeShape(Base, TimestampMixin):
"""One extracted code shape and its classification against canon (#2787).
The accounting half of the pattern system (governing note 2786): the
snippet library records CANON (small); this ledger accounts for EVERY
shape the coverage extractor finds in a bound repo (total). A row's
status says how the shape relates to canon — it IS a snippet's reference
(`canonical`), conforms to one (`instance` — snippet_id may point at
another project's snippet, so family canon counts), departs deliberately
(`variant`, with the why in `reason`), was judged one-off (`exempt`,
a recorded judgment rather than silence), or awaits judgment
(`unclassified` — the todo).
Identity is (project, repo_key, path, symbol, kind) — kind is part of it
because one file can define `.foo` (css) and `foo` (sym) as distinct
shapes. A rename therefore reads as vanish + new row: accepted for v1,
because chasing renames needs content identity the extractor doesn't
have. `vanished_at` keeps the history instead of deleting it.
snippet_id is SET NULL on snippet deletion: the classification's target
is gone but the judgment happened; the sync pass (step 2) re-files such
rows as unclassified so they rejoin the todo instead of dangling.
`signature` / `body_sha` (#2792) are the shape's content fingerprint —
its definition line and a whitespace/comment-insensitive hash of its
block — refreshed by every sync. They are what the mechanical proposer
matches on and what a later drift recheck compares against; the ledger
still never stores code bodies.
`classified_sha` remembers the fingerprint a judgment was made at;
when a later sync sees the body change under an instance/variant, the
row is flagged `recheck_at` (the judgment stands, it just asks to be
confirmed again) and a `drifted` event is written. `diverges_from`
(#2793) is the button-B flag: a shape new since the previous refresh, in
a directory+kind where one canon dominates the judged siblings, that the
proposer did not match to that canon — "button B appeared where button
A is canon: divergence or variant? classify it." Both clear on judgment.
The proposal columns hold the proposer's standing suggestion for an
UNCLASSIFIED row: `proposed_snippet_id` + `proposal_basis` + score for
"looks like an instance of #N", or `proposal_basis="derive"` +
`proposal_group` for "repeats with no canon". `proposed_sha` is the
body_sha the row was last examined at, so a refresh re-examines only
what changed. A judgment clears the proposal — the machine proposes,
judgment classifies.
"""
__tablename__ = "code_shapes"
__table_args__ = (
UniqueConstraint(
"project_id", "repo_key", "path", "symbol", "kind",
name="uq_code_shapes_identity",
),
Index("ix_code_shapes_project_status", "project_id", "status"),
Index("ix_code_shapes_snippet", "snippet_id"),
Index("ix_code_shapes_proposed", "project_id", "proposed_snippet_id"),
Index("ix_code_shapes_diverges", "project_id", "diverges_from"),
)
id: Mapped[int] = mapped_column(primary_key=True)
project_id: Mapped[int] = mapped_column(
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
repo_key: Mapped[str] = mapped_column(Text, nullable=False)
path: Mapped[str] = mapped_column(Text, nullable=False)
symbol: Mapped[str] = mapped_column(Text, nullable=False)
kind: Mapped[str] = mapped_column(Text, nullable=False) # "css" | "sym"
status: Mapped[str] = mapped_column(Text, nullable=False, default="unclassified")
snippet_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
reason_code: Mapped[str | None] = mapped_column(Text, nullable=True) # REASON_CODES (#2874)
classified_by: Mapped[str | None] = mapped_column(Text, nullable=True)
classified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
first_seen_commit: Mapped[str] = mapped_column(Text, default="")
last_seen_commit: Mapped[str] = mapped_column(Text, default="")
vanished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
signature: Mapped[str] = mapped_column(Text, default="")
body_sha: Mapped[str] = mapped_column(Text, default="")
proposed_snippet_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
proposal_basis: Mapped[str | None] = mapped_column(Text, nullable=True)
proposal_score: Mapped[float | None] = mapped_column(Float, nullable=True)
proposal_group: Mapped[str | None] = mapped_column(Text, nullable=True)
proposed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
proposed_sha: Mapped[str] = mapped_column(Text, default="")
classified_sha: Mapped[str] = mapped_column(Text, default="")
recheck_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
diverges_from: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
@property
def proposal(self) -> dict | None:
"""The standing proposal as one object, or None when the proposer
has nothing to say about this row."""
if self.proposed_snippet_id is None and not self.proposal_group:
return None
out: dict = {"basis": self.proposal_basis, "score": self.proposal_score}
if self.proposed_snippet_id is not None:
out["snippet_id"] = self.proposed_snippet_id
if self.proposal_group:
out["group"] = self.proposal_group
return out
def to_dict(self) -> dict:
return {
"id": self.id,
"project_id": self.project_id,
"repo_key": self.repo_key,
"path": self.path,
"symbol": self.symbol,
"kind": self.kind,
"status": self.status,
"snippet_id": self.snippet_id,
"reason": self.reason,
"reason_code": self.reason_code,
"classified_by": self.classified_by,
"classified_at": iso(self.classified_at),
"first_seen_commit": self.first_seen_commit,
"last_seen_commit": self.last_seen_commit,
"vanished_at": iso(self.vanished_at),
"signature": self.signature,
"body_sha": self.body_sha,
"proposal": self.proposal,
"classified_sha": self.classified_sha,
"recheck_at": iso(self.recheck_at),
"diverges_from": self.diverges_from,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
def to_compact(self) -> dict:
"""The row as an audit reads it (#2868): identity, standing, the
definition line and the proposer's word — none of the bookkeeping
(commits, shas, timestamps). A 500-row page of these fits the tool
budget; a page of to_dict() does not."""
out = {
"path": self.path,
"symbol": self.symbol,
"kind": self.kind,
"status": self.status,
"signature": self.signature,
}
if self.snippet_id is not None:
out["snippet_id"] = self.snippet_id
if self.classified_by:
out["by"] = self.classified_by
if self.reason_code:
out["reason_code"] = self.reason_code
proposal = self.proposal
if proposal:
out["proposal"] = proposal
if self.diverges_from is not None:
out["diverges_from"] = self.diverges_from
if self.recheck_at is not None:
out["recheck"] = True
return out
# How a uses edge was established (#2870): who/what said "this shape calls
# that canon". `reference` is the proposer's mechanical by-name hit on the
# body (language-gated, #2871); `hook` is write-path evidence (pulled the
# snippet, then wrote code naming its symbol); agent/audit/import are
# judgments carried on classify_shapes(..., uses=[...]).
USE_BASES = ("reference", "hook", "agent", "audit", "import")
class CodeShapeUse(Base):
"""One consumption edge: shape → canonical snippet it calls/uses (#2870).
Conformance (CodeShape.status/snippet_id) answers "what shape is this";
this table answers "what does it use" — many per shape. A service function
that is an instance of the service-function convention AND a consumer of
hash_token has one snippet_id and one uses edge. Cascades with both ends:
a use of a deleted snippet is no longer a fact worth keeping.
"""
__tablename__ = "code_shape_uses"
__table_args__ = (
UniqueConstraint("shape_id", "snippet_id", name="uq_code_shape_uses_shape_snippet"),
Index("ix_code_shape_uses_snippet", "snippet_id"),
)
id: Mapped[int] = mapped_column(primary_key=True)
shape_id: Mapped[int] = mapped_column(
Integer, ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False
)
snippet_id: Mapped[int] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
)
basis: Mapped[str] = mapped_column(Text, nullable=False)
evidence: Mapped[str | None] = mapped_column(Text, nullable=True)
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,
"snippet_id": self.snippet_id,
"basis": self.basis,
"evidence": self.evidence,
"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")
class CodeShapeEvent(Base):
"""One state change in a shape's life — the what-was-used-when record.
"We used #N here from <date>, #M replaced it at commit C, reason R" is a
question the ledger row alone cannot answer once it has moved on; this
table keeps each judgment (status, snippet, who, why, at which commit)
and each presence change (vanished / reappeared / drifted) as it
happened. Denormalised path/symbol/kind so a directory's history reads
without joining; `snippet_id` is deliberately FK-free — history outlives
the snippet it names, which is the point.
"""
__tablename__ = "code_shape_events"
__table_args__ = (
Index("ix_code_shape_events_shape", "shape_id", "at"),
Index("ix_code_shape_events_project_path", "project_id", "path"),
)
id: Mapped[int] = mapped_column(primary_key=True)
shape_id: Mapped[int] = mapped_column(
Integer, ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False
)
project_id: Mapped[int] = mapped_column(Integer, nullable=False)
path: Mapped[str] = mapped_column(Text, nullable=False)
symbol: Mapped[str] = mapped_column(Text, nullable=False)
kind: Mapped[str] = mapped_column(Text, nullable=False)
event: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str | None] = mapped_column(Text, nullable=True)
snippet_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
classified_by: Mapped[str | None] = mapped_column(Text, nullable=True)
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
commit: Mapped[str] = mapped_column(Text, default="")
at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
def to_dict(self) -> dict:
return {
"id": self.id,
"shape_id": self.shape_id,
"project_id": self.project_id,
"path": self.path,
"symbol": self.symbol,
"kind": self.kind,
"event": self.event,
"status": self.status,
"snippet_id": self.snippet_id,
"classified_by": self.classified_by,
"reason": self.reason,
"commit": self.commit,
"at": iso(self.at),
}
+5 -5
View File
@@ -20,7 +20,7 @@ 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
from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
@@ -56,8 +56,8 @@ class DesignSystem(Base, TimestampMixin, SoftDeleteMixin):
"description": self.description or "",
"guidance": self.guidance or "",
"parent_id": self.parent_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
@@ -153,6 +153,6 @@ class DesignToken(Base, TimestampMixin, SoftDeleteMixin):
"rationale": self.rationale,
"supersedes": self.supersedes or [],
"order_index": self.order_index,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
+45
View File
@@ -0,0 +1,45 @@
from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin, iso
class ForgeConnection(Base, TimestampMixin):
"""One user's read-only credential for one git forge host (#2778).
The keyring model: a user owns a set of connections and every server-side
forge read for a project runs on the PROJECT OWNER's set, resolved by the
repo's host. One row per (user, host) — the repo's host picks the
connection deterministically, so there is no "default forge" pointer to
maintain or tie-break.
`host` is derived from `base_url` at write time and stored because it is
the lookup key; the service layer keeps the two in step. The token is a
secret: to_dict never includes it, and no route may return it.
"""
__tablename__ = "forge_connections"
__table_args__ = (
UniqueConstraint("user_id", "host", name="uq_forge_connections_user_host"),
)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
kind: Mapped[str] = mapped_column(Text, nullable=False)
base_url: Mapped[str] = mapped_column(Text, nullable=False)
host: Mapped[str] = mapped_column(Text, nullable=False)
token: Mapped[str] = mapped_column(Text, nullable=False)
def to_dict(self) -> dict:
return {
"id": self.id,
"user_id": self.user_id,
"kind": self.kind,
"base_url": self.base_url,
"host": self.host,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
+4 -4
View File
@@ -4,7 +4,7 @@ from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from scribe.models import Base
from scribe.models.base import CreatedAtMixin, TimestampMixin
from scribe.models.base import CreatedAtMixin, TimestampMixin, iso
class Group(Base, TimestampMixin):
@@ -27,8 +27,8 @@ class Group(Base, TimestampMixin):
"name": self.name,
"description": self.description,
"created_by": self.created_by,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
@@ -53,5 +53,5 @@ class GroupMembership(Base, CreatedAtMixin):
"group_id": self.group_id,
"user_id": self.user_id,
"role": self.role,
"created_at": self.created_at.isoformat(),
"created_at": iso(self.created_at),
}
+2 -4
View File
@@ -4,9 +4,10 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin
class InvitationToken(Base):
class InvitationToken(Base, CreatedAtMixin):
__tablename__ = "invitation_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -15,9 +16,6 @@ class InvitationToken(Base):
invited_by: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = (
Index("ix_invitation_tokens_token_hash", "token_hash"),
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin
from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class Milestone(Base, TimestampMixin, SoftDeleteMixin):
@@ -30,6 +30,6 @@ class Milestone(Base, TimestampMixin, SoftDeleteMixin):
"body": self.body,
"status": self.status,
"order_index": self.order_index,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
+7 -11
View File
@@ -6,7 +6,7 @@ from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin
from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class TaskStatus(str, enum.Enum):
@@ -105,18 +105,14 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
"milestone_id": self.milestone_id,
"status": self.status,
"priority": self.priority,
"due_date": self.due_date.isoformat() if self.due_date else None,
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"due_date": iso(self.due_date),
"started_at": iso(self.started_at),
"completed_at": iso(self.completed_at),
"recurrence_rule": self.recurrence_rule,
"recurrence_next_spawn_at": (
self.recurrence_next_spawn_at.isoformat()
if self.recurrence_next_spawn_at
else None
),
"recurrence_next_spawn_at": iso(self.recurrence_next_spawn_at),
"is_task": self.is_task,
"note_type": self.note_type or "note",
"task_kind": self.task_kind,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin
from scribe.models.base import TimestampMixin, iso
class NoteDraft(Base, TimestampMixin):
@@ -25,6 +25,6 @@ class NoteDraft(Base, TimestampMixin):
"original_body": self.original_body,
"instruction": self.instruction,
"scope": self.scope,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
+2 -2
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin
from scribe.models.base import CreatedAtMixin, iso
class NoteSupersession(Base, CreatedAtMixin):
@@ -66,5 +66,5 @@ class NoteSupersession(Base, CreatedAtMixin):
"id": self.id,
"superseder_id": self.superseder_id,
"superseded_id": self.superseded_id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"created_at": iso(self.created_at),
}
+4 -8
View File
@@ -1,15 +1,14 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, Index, Integer, Text
from sqlalchemy import Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin, iso
SURFACED = "surfaced"
PULLED = "pulled"
class NoteUsageEvent(Base):
class NoteUsageEvent(Base, CreatedAtMixin):
"""One row per time a note was SURFACED to the agent, or PULLED in full.
Answers the question RetrievalLog cannot: not "what did the ranker return
@@ -44,9 +43,6 @@ class NoteUsageEvent(Base):
__tablename__ = "note_usage_events"
id: Mapped[int] = mapped_column(primary_key=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
note_id: Mapped[int] = mapped_column(Integer, nullable=False)
# 'surfaced' | 'pulled'
@@ -81,7 +77,7 @@ class NoteUsageEvent(Base):
def to_dict(self) -> dict:
return {
"id": self.id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"created_at": iso(self.created_at),
"user_id": self.user_id,
"note_id": self.note_id,
"event": self.event,
+2 -2
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ARRAY, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin
from scribe.models.base import CreatedAtMixin, iso
class NoteVersion(Base, CreatedAtMixin):
@@ -26,7 +26,7 @@ class NoteVersion(Base, CreatedAtMixin):
"tags": self.tags or [],
"pin_kind": self.pin_kind,
"pin_label": self.pin_label,
"created_at": self.created_at.isoformat(),
"created_at": iso(self.created_at),
}
if include_body:
d["body"] = self.body
+3 -3
View File
@@ -5,7 +5,7 @@ from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin
from scribe.models.base import CreatedAtMixin, iso
class Notification(Base, CreatedAtMixin):
@@ -26,6 +26,6 @@ class Notification(Base, CreatedAtMixin):
"user_id": self.user_id,
"type": self.type,
"payload": self.payload,
"read_at": self.read_at.isoformat() if self.read_at else None,
"created_at": self.created_at.isoformat(),
"read_at": iso(self.read_at),
"created_at": iso(self.created_at),
}
+2 -4
View File
@@ -4,9 +4,10 @@ from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin
class PasswordResetToken(Base):
class PasswordResetToken(Base, CreatedAtMixin):
__tablename__ = "password_reset_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
@@ -14,9 +15,6 @@ class PasswordResetToken(Base):
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = (
Index("ix_password_reset_tokens_token_hash", "token_hash"),
+13 -3
View File
@@ -2,7 +2,7 @@ import enum
from sqlalchemy import BigInteger, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin, SoftDeleteMixin
from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class ProjectStatus(str, enum.Enum):
@@ -27,6 +27,15 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
design_system_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True
)
# The per-project forge pin (#2778). NULL is the ordinary state: forge
# reads resolve against the owner's keyring by repo host. When set, the
# project's forge reads use ONLY this connection — an explicit, auditable
# choice, constrained by the service layer to a connection the project
# OWNER holds (never a collaborator's token).
forge_connection_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("forge_connections.id", ondelete="SET NULL"),
nullable=True,
)
def to_dict(self) -> dict:
return {
@@ -38,6 +47,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
"status": self.status,
"color": self.color,
"design_system_id": self.design_system_id,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"forge_connection_id": self.forge_connection_id,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
+8 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin
from scribe.models.base import TimestampMixin, iso
class RepoBinding(Base, TimestampMixin):
@@ -28,6 +28,10 @@ class RepoBinding(Base, TimestampMixin):
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
)
repo_key: Mapped[str] = mapped_column(Text, nullable=False)
# The branch the coverage refresh reads for this binding (#2873); NULL =
# the forge's default branch. Chosen at bind time so a dev-first project
# can have its ledger follow dev instead of waiting for the merge.
ref: Mapped[str | None] = mapped_column(Text, nullable=True)
def to_dict(self) -> dict:
return {
@@ -35,6 +39,7 @@ class RepoBinding(Base, TimestampMixin):
"user_id": self.user_id,
"project_id": self.project_id,
"repo_key": self.repo_key,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"ref": self.ref,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
+5 -1
View File
@@ -5,6 +5,7 @@ from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import iso
class RetrievalLog(Base):
@@ -23,6 +24,9 @@ class RetrievalLog(Base):
__tablename__ = "retrieval_logs"
id: Mapped[int] = mapped_column(primary_key=True)
# Declared here rather than via CreatedAtMixin on purpose: the composite
# index below orders on `created_at.desc()`, which needs the column object
# in this class body — a mixin's column is not in scope there.
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
@@ -54,7 +58,7 @@ class RetrievalLog(Base):
def to_dict(self) -> dict:
return {
"id": self.id,
"created_at": self.created_at.isoformat() if self.created_at else None,
"created_at": iso(self.created_at),
"user_id": self.user_id,
"source": self.source,
"query": self.query,
+10 -34
View File
@@ -4,10 +4,10 @@ from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index,
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import SoftDeleteMixin
from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
class Rulebook(Base, SoftDeleteMixin):
class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "rulebooks"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
@@ -19,14 +19,6 @@ class Rulebook(Base, SoftDeleteMixin):
always_on: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False, server_default="false"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict:
return {
@@ -35,12 +27,12 @@ class Rulebook(Base, SoftDeleteMixin):
"title": self.title,
"description": self.description or "",
"always_on": self.always_on,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
class RulebookTopic(Base, SoftDeleteMixin):
class RulebookTopic(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "rulebook_topics"
# Partial unique: a title is unique among LIVE topics in a rulebook, so a
# trashed topic doesn't block recreating/restoring the same title.
@@ -58,14 +50,6 @@ class RulebookTopic(Base, SoftDeleteMixin):
title: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
order_index: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict:
return {
@@ -74,12 +58,12 @@ class RulebookTopic(Base, SoftDeleteMixin):
"title": self.title,
"description": self.description or "",
"order_index": self.order_index,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
class Rule(Base, SoftDeleteMixin):
class Rule(Base, TimestampMixin, SoftDeleteMixin):
__tablename__ = "rules"
# Partial unique: title unique among LIVE rules in a topic (soft-deleted
# rules don't block recreating/restoring the same title).
@@ -109,14 +93,6 @@ class Rule(Base, SoftDeleteMixin):
why: Mapped[str | None] = mapped_column(Text, nullable=True)
how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
order_index: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
def to_dict(self) -> dict:
return {
@@ -128,8 +104,8 @@ class Rule(Base, SoftDeleteMixin):
"why": self.why or "",
"how_to_apply": self.how_to_apply or "",
"order_index": self.order_index,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
+5 -5
View File
@@ -2,7 +2,7 @@ from sqlalchemy import CheckConstraint, ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin
from scribe.models.base import TimestampMixin, iso
class ProjectShare(Base, TimestampMixin):
@@ -37,8 +37,8 @@ class ProjectShare(Base, TimestampMixin):
"shared_with_group_id": self.shared_with_group_id,
"permission": self.permission,
"invited_by": self.invited_by,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
@@ -74,6 +74,6 @@ class NoteShare(Base, TimestampMixin):
"shared_with_group_id": self.shared_with_group_id,
"permission": self.permission,
"invited_by": self.invited_by,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Index, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin, TimestampMixin, SoftDeleteMixin
from scribe.models.base import CreatedAtMixin, SoftDeleteMixin, TimestampMixin, iso
class System(Base, TimestampMixin, SoftDeleteMixin):
@@ -44,8 +44,8 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
"color": self.color,
"status": self.status,
"order_index": self.order_index,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
+3 -3
View File
@@ -2,7 +2,7 @@ from sqlalchemy import ForeignKey, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import TimestampMixin
from scribe.models.base import TimestampMixin, iso
class TaskLog(Base, TimestampMixin):
@@ -21,6 +21,6 @@ class TaskLog(Base, TimestampMixin):
"user_id": self.user_id,
"content": self.content,
"duration_minutes": self.duration_minutes,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
+2 -2
View File
@@ -2,7 +2,7 @@ from sqlalchemy import Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin
from scribe.models.base import CreatedAtMixin, iso
class User(Base, CreatedAtMixin):
@@ -26,6 +26,6 @@ class User(Base, CreatedAtMixin):
"username": self.username,
"email": self.email,
"role": self.role,
"created_at": self.created_at.isoformat(),
"created_at": iso(self.created_at),
"has_password": self.password_hash is not None,
}
+19 -67
View File
@@ -19,18 +19,10 @@ from scribe.services.backup import (
restore_full_backup,
)
from scribe.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
from scribe.services.forge import (
FORGE_BASE_URL_KEY,
FORGE_KIND_KEY,
FORGE_KINDS,
FORGE_TOKEN_KEY,
ForgeError,
forge_config,
get_forge,
)
from scribe.services.logging import get_logs, get_log_stats, log_audit
from scribe.services.notifications import send_invitation_email
from scribe.services.settings import (
SECRET_MASK,
get_admin_setting,
set_admin_setting,
set_setting,
@@ -125,7 +117,7 @@ async def get_smtp():
config = await get_smtp_config()
# Mask password
if config.get("smtp_password"):
config["smtp_password"] = "********"
config["smtp_password"] = SECRET_MASK
return jsonify(config)
@@ -139,7 +131,7 @@ async def update_smtp():
for key in SMTP_SETTING_KEYS:
if key in data:
# Skip password if it's the mask placeholder
if key == "smtp_password" and data[key] == "********":
if key == "smtp_password" and data[key] == SECRET_MASK:
continue
settings_to_save[key] = str(data[key])
@@ -166,88 +158,48 @@ async def test_smtp():
return jsonify({"error": str(e)}), 500
_TOKEN_MASK = "********"
# The forge CONFIG moved to per-user keyring rows (#2778, Settings → Git
# forges); what stays admin is the webhook secret, because the push endpoint
# is one URL per instance and authenticates deliveries, not users.
@admin_bp.route("/forge", methods=["GET"])
@admin_bp.route("/forge-webhook", methods=["GET"])
@admin_required
async def get_forge_settings():
async def get_forge_webhook_settings():
from scribe.config import Config
from scribe.routes.webhooks import FORGE_WEBHOOK_SECRET_KEY
cfg = await forge_config()
webhook_secret = (
await get_admin_setting(FORGE_WEBHOOK_SECRET_KEY, "")
or Config.FORGE_WEBHOOK_SECRET
)
return jsonify({
"kind": cfg["kind"],
"base_url": cfg["base_url"],
# Secrets never leave the server — the smtp_password convention:
# masked when set, empty when not.
"token": _TOKEN_MASK if cfg["token"] else "",
"webhook_secret": _TOKEN_MASK if webhook_secret else "",
"configured": bool(await get_forge()),
"kinds": list(FORGE_KINDS),
"webhook_secret": SECRET_MASK if webhook_secret else "",
})
@admin_bp.route("/forge", methods=["PUT"])
@admin_bp.route("/forge-webhook", methods=["PUT"])
@admin_required
async def update_forge_settings():
data = await request.get_json() or {}
uid = get_current_user_id()
kind = str(data.get("kind", "")).strip().lower()
if kind and kind not in FORGE_KINDS:
return jsonify({"error": f"Unknown forge kind {kind!r}"}), 400
base_url = str(data.get("base_url", "")).strip().rstrip("/")
if base_url and not base_url.startswith(("http://", "https://")):
return jsonify({"error": "Forge base URL must use http or https"}), 400
await set_admin_setting(FORGE_KIND_KEY, kind)
await set_admin_setting(FORGE_BASE_URL_KEY, base_url)
token = data.get("token")
# The mask coming back means "unchanged" — the form round-trips what GET
# showed it, and storing the mask would silently break the integration.
if token is not None and token != _TOKEN_MASK:
await set_admin_setting(FORGE_TOKEN_KEY, str(token))
async def update_forge_webhook_settings():
from scribe.routes.webhooks import FORGE_WEBHOOK_SECRET_KEY
data = await request.get_json() or {}
uid = get_current_user_id()
webhook_secret = data.get("webhook_secret")
if webhook_secret is not None and webhook_secret != _TOKEN_MASK:
# The mask coming back means "unchanged" — the form round-trips what GET
# showed it, and storing the mask would silently break the integration.
if webhook_secret is not None and webhook_secret != SECRET_MASK:
await set_admin_setting(FORGE_WEBHOOK_SECRET_KEY, str(webhook_secret))
# The token is deliberately absent from the audit detail.
# The secret is deliberately absent from the audit detail.
await log_audit(
"forge_config", user_id=uid, username=g.user.username,
ip_address=request.remote_addr,
details={"kind": kind, "base_url": base_url},
"forge_webhook_config", user_id=uid, username=g.user.username,
ip_address=request.remote_addr, details={},
)
return jsonify({"status": "ok"})
@admin_bp.route("/forge/test", methods=["POST"])
@admin_required
async def test_forge():
"""Probe the SAVED forge config: reachability and token acceptance in one
press, so a misconfiguration is visible now rather than as silent
fallbacks later (#2663's lesson, applied to integrations)."""
uid = get_current_user_id()
forge = await get_forge()
if forge is None:
return jsonify({"error": "Forge is not configured — save kind, base URL and token first"}), 400
try:
result = await forge.check()
except ForgeError as e:
return jsonify({"error": str(e)}), 502
await log_audit(
"forge_test", user_id=uid, username=g.user.username,
ip_address=request.remote_addr,
details={"ok": True, "username": result.get("username", "")},
)
return jsonify(result)
@admin_bp.route("/logs", methods=["GET"])
@admin_required
async def list_logs():
+28 -33
View File
@@ -15,9 +15,10 @@ one and returns None for the other:
those two IS the intent: distinguishing them would confirm the existence of
records the caller may not see.
"""
from quart import Blueprint, g, jsonify, request
from quart import Blueprint, jsonify, request
from scribe.auth import login_required
from scribe.auth import get_current_user_id, login_required
from scribe.routes.utils import not_found
from scribe.services import design_systems as ds_svc
from scribe.services.design_starter_roles import (
DEFAULT_TOKEN_PREFIX,
@@ -28,12 +29,6 @@ from scribe.services.design_systems import DesignSystemCycle
design_systems_bp = Blueprint("design_systems", __name__, url_prefix="/api")
def _uid() -> int:
return g.user.id
def _not_found(what: str = "design system"):
return jsonify({"error": f"{what} not found"}), 404
# ── Design systems ──────────────────────────────────────────────────────
@@ -43,7 +38,7 @@ def _not_found(what: str = "design system"):
async def list_design_systems():
"""The caller's design systems. An empty list is the ordinary state for an
install that has never made one, not an error."""
rows = await ds_svc.list_design_systems(_uid())
rows = await ds_svc.list_design_systems(get_current_user_id())
return jsonify({"design_systems": [s.to_dict() for s in rows]})
@@ -55,7 +50,7 @@ async def create_design_system():
if not title:
return jsonify({"error": "title is required"}), 400
system = await ds_svc.create_design_system(
user_id=_uid(),
user_id=get_current_user_id(),
title=title,
description=data.get("description") or None,
guidance=data.get("guidance") or None,
@@ -84,9 +79,9 @@ async def list_starter_role_groups():
@design_systems_bp.get("/design-systems/<int:design_system_id>")
@login_required
async def get_design_system(design_system_id: int):
system = await ds_svc.get_design_system(_uid(), design_system_id)
system = await ds_svc.get_design_system(get_current_user_id(), design_system_id)
if system is None:
return _not_found()
return not_found("Design system")
return jsonify(system.to_dict())
@@ -102,19 +97,19 @@ async def update_design_system(design_system_id: int):
if "parent_id" in data:
fields["parent_id"] = data["parent_id"]
try:
system = await ds_svc.update_design_system(_uid(), design_system_id, **fields)
system = await ds_svc.update_design_system(get_current_user_id(), design_system_id, **fields)
except DesignSystemCycle as exc:
return jsonify({"error": str(exc)}), 400
if system is None:
return _not_found()
return not_found("Design system")
return jsonify(system.to_dict())
@design_systems_bp.delete("/design-systems/<int:design_system_id>")
@login_required
async def delete_design_system(design_system_id: int):
if not await ds_svc.delete_design_system(_uid(), design_system_id):
return _not_found()
if not await ds_svc.delete_design_system(get_current_user_id(), design_system_id):
return not_found("Design system")
return "", 204
@@ -127,9 +122,9 @@ async def resolve_design_system(design_system_id: int):
this returns what it ends up being. Both are real questions and answering
only one would make the other a client-side computation.
"""
resolved = await ds_svc.resolve_design_system(_uid(), design_system_id)
resolved = await ds_svc.resolve_design_system(get_current_user_id(), design_system_id)
if resolved is None:
return _not_found()
return not_found("Design system")
return jsonify({
"design_system_id": design_system_id,
"tokens": [t.to_dict() for t in resolved],
@@ -149,9 +144,9 @@ async def get_design_system_stylesheet(design_system_id: int):
`:root`, so the generator takes it as a parameter.
"""
root = (request.args.get("root") or ":root").strip() or ":root"
result = await ds_svc.stylesheet_for_system(_uid(), design_system_id, root)
result = await ds_svc.stylesheet_for_system(get_current_user_id(), design_system_id, root)
if result is None:
return _not_found()
return not_found("Design system")
if request.args.get("format") == "css":
return result["css"], 200, {"Content-Type": "text/css; charset=utf-8"}
return jsonify(result)
@@ -168,10 +163,10 @@ async def check_snippets_against_system(design_system_id: int):
"""
project_id = request.args.get("project_id", type=int) or 0
result = await ds_svc.check_snippets_against_system(
_uid(), design_system_id, project_id
get_current_user_id(), design_system_id, project_id
)
if result is None:
return _not_found()
return not_found("Design system")
return jsonify(result)
@@ -181,9 +176,9 @@ async def check_snippets_against_system(design_system_id: int):
@login_required
async def list_design_tokens(design_system_id: int):
"""This system's OWN tokens — its override set, not its effective set."""
if await ds_svc.get_design_system(_uid(), design_system_id) is None:
return _not_found()
rows = await ds_svc.list_tokens(_uid(), design_system_id)
if await ds_svc.get_design_system(get_current_user_id(), design_system_id) is None:
return not_found("Design system")
rows = await ds_svc.list_tokens(get_current_user_id(), design_system_id)
return jsonify({"tokens": [t.to_dict() for t in rows]})
@@ -195,7 +190,7 @@ async def create_design_token(design_system_id: int):
if not name:
return jsonify({"error": "name is required"}), 400
token = await ds_svc.create_token(
user_id=_uid(),
user_id=get_current_user_id(),
design_system_id=design_system_id,
name=name,
value_by_mode=data.get("value_by_mode"),
@@ -206,7 +201,7 @@ async def create_design_token(design_system_id: int):
order_index=data.get("order_index") or 0,
)
if token is None:
return _not_found()
return not_found("Design system")
return jsonify(token.to_dict()), 201
@@ -221,17 +216,17 @@ async def update_design_token(token_id: int):
"supersedes", "order_index",
)
}
token = await ds_svc.update_token(_uid(), token_id, **fields)
token = await ds_svc.update_token(get_current_user_id(), token_id, **fields)
if token is None:
return _not_found("design token")
return not_found("Design token")
return jsonify(token.to_dict())
@design_systems_bp.delete("/design-tokens/<int:token_id>")
@login_required
async def delete_design_token(token_id: int):
if not await ds_svc.delete_token(_uid(), token_id):
return _not_found("design token")
if not await ds_svc.delete_token(get_current_user_id(), token_id):
return not_found("Design token")
return "", 204
@@ -247,9 +242,9 @@ async def set_project_design_system(project_id: int):
"""
data = await request.get_json() or {}
ok = await ds_svc.set_project_design_system(
_uid(), project_id, data.get("design_system_id")
get_current_user_id(), project_id, data.get("design_system_id")
)
if not ok:
return _not_found("project or design system")
return not_found("Project or design system")
return jsonify({"project_id": project_id,
"design_system_id": data.get("design_system_id")})
+8 -54
View File
@@ -26,22 +26,6 @@ from scribe.services import dedup as dedup_svc
from scribe.services import supersession as supersession_svc
from scribe.services.note_usage import record_pulled
async def _attach_supersession(uid: int, note_id: int, data: dict) -> None:
"""Both directions of the supersession relation on a note payload.
Mirrors the MCP helper of the same name — the two surfaces must agree about
what a note's payload says, or the web UI and the agent would disagree about
whether a record is current.
Omitted when empty: a field that always says nothing trains readers to skip
fields, which is what `consolidated_at` cost (#2483).
"""
rel = await supersession_svc.get_relations(uid, note_id)
if rel["supersedes"]:
data["supersedes"] = rel["supersedes"]
if rel["superseded_by"]:
data["superseded_by"] = rel["superseded_by"]
from scribe.services.note_versions import list_versions, get_version
logger = logging.getLogger(__name__)
@@ -142,7 +126,7 @@ async def create_note_route():
# may not write the target. The note itself was created.
return jsonify({"error": str(exc), "note": note.to_dict()}), 403
out = note.to_dict()
await _attach_supersession(uid, note.id, out)
await supersession_svc.attach_relations(uid, note.id, out)
return jsonify(out), 201
@@ -241,13 +225,17 @@ async def get_note_route(note_id: int):
# injected line useful?" is answered by agent pulls alone, and a human
# clicking a link would inflate exactly the number #1038 and #2085 gate on.
record_pulled(user_id=uid, note_id=note_id, source="rest_note")
await _attach_supersession(uid, note_id, data)
await supersession_svc.attach_relations(uid, note_id, data)
return jsonify(data)
@notes_bp.route("/<int:note_id>", methods=["PUT"])
@notes_bp.route("/<int:note_id>", methods=["PUT", "PATCH"])
@login_required
async def update_note_route(note_id: int):
"""Partial update — only the keys present in the payload change. PUT and
PATCH are the same handler on purpose: the form sends the field set it
edited, and the two verbs used to be two near-identical copies of this
function that drifted (one carried the supersedes contract, one did not)."""
uid = get_current_user_id()
# Share-aware: resolve through the ACL and write as the OWNER, so a shared
# editor's save isn't rejected by the owner-scoped update service.
@@ -290,44 +278,10 @@ async def update_note_route(note_id: int):
except PermissionError as exc:
return jsonify({"error": str(exc)}), 403
out = note.to_dict()
await _attach_supersession(uid, note_id, out)
await supersession_svc.attach_relations(uid, note_id, out)
return jsonify(out)
@notes_bp.route("/<int:note_id>", methods=["PATCH"])
@login_required
async def patch_note_route(note_id: int):
uid = get_current_user_id()
result = await get_note_for_user(uid, note_id)
if result is None:
return not_found("Note")
note_obj, _ = result
if not await can_write_note(uid, note_id):
return jsonify({"error": "Permission denied"}), 403
owner_uid = note_obj.user_id
data = await request.get_json()
fields = {}
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
if key in data:
fields[key] = data[key]
if "due_date" in data:
if data["due_date"]:
result = parse_iso_date(data["due_date"], "due_date")
if isinstance(result, tuple):
return result
fields["due_date"] = result
else:
fields["due_date"] = None
if "tags" in data:
fields["tags"] = data["tags"]
try:
note = await update_note(owner_uid, note_id, **fields)
except ValueError as e:
return jsonify({"error": str(e)}), 400
if note is None:
return not_found("Note")
return jsonify(note.to_dict())
@notes_bp.route("/<int:note_id>", methods=["DELETE"])
@login_required
+79 -47
View File
@@ -24,6 +24,35 @@ plugin_bp = Blueprint("plugin", __name__, url_prefix="/api/plugin")
_MARKETPLACE_KEY = "plugin_marketplace_url"
def _int_list(raw: str | None) -> list[int]:
"""A comma-separated id list from the query string; non-ints dropped."""
return [int(p) for p in (raw or "").split(",") if p.strip().isdigit()]
async def _project_scope() -> tuple[int, str, str]:
"""(project_id, repo, unbound_repo) from the request's `project_id` /
`repo` query args — the one resolution every plugin endpoint shares.
An explicit project_id wins; otherwise the repo remote is resolved
through the caller's bindings, and a remote nobody bound comes back as
`unbound_repo` (normalised) so /context can say "bind this repo".
"""
try:
project_id = int(request.args.get("project_id", 0) or 0)
except (TypeError, ValueError):
project_id = 0
repo = (request.args.get("repo") or "").strip()
unbound_repo = ""
if repo and not project_id:
resolved = await repo_bindings_svc.resolve_project(g.user.id, repo)
if resolved:
project_id = resolved
else:
unbound_repo = repo_bindings_svc.normalize_repo_key(repo)
return project_id, repo, unbound_repo
@plugin_bp.get("/context")
@login_required
async def session_context():
@@ -37,20 +66,7 @@ async def session_context():
project_id (optional int) — explicit override, mainly for manual/ad-hoc
curl testing; takes precedence over `repo` when set.
"""
try:
project_id = int(request.args.get("project_id", 0) or 0)
except (TypeError, ValueError):
project_id = 0
unbound_repo = ""
repo = (request.args.get("repo") or "").strip()
if repo and not project_id:
resolved = await repo_bindings_svc.resolve_project(g.user.id, repo)
if resolved:
project_id = resolved
else:
unbound_repo = repo_bindings_svc.normalize_repo_key(repo)
project_id, _repo, unbound_repo = await _project_scope()
result = await plugin_ctx_svc.build_session_context(
g.user.id, project_id, unbound_repo=unbound_repo
)
@@ -77,22 +93,8 @@ async def autoinject_retrieve():
session; skipped so each note injects at most once.
"""
q = (request.args.get("q") or "").strip()
try:
project_id = int(request.args.get("project_id", 0) or 0)
except (TypeError, ValueError):
project_id = 0
repo = (request.args.get("repo") or "").strip()
if repo and not project_id:
resolved = await repo_bindings_svc.resolve_project(g.user.id, repo)
if resolved:
project_id = resolved
exclude_ids = [
int(p) for p in (request.args.get("exclude_ids") or "").split(",")
if p.strip().isdigit()
]
project_id, _repo, _unbound = await _project_scope()
exclude_ids = _int_list(request.args.get("exclude_ids"))
result = await plugin_ctx_svc.build_autoinject_hint(
g.user.id, q, project_id=project_id, exclude_ids=exclude_ids
)
@@ -120,32 +122,62 @@ async def write_path_prior_art():
is NOT used as the location `repo` filter — see the
service docstring for why those two differ.
project_id (opt) — explicit project scope override (ad-hoc/testing).
exclude_ids (opt) — comma-separated ids already surfaced this session.
exclude_ids (opt) — comma-separated REUSE-class ids already surfaced
this session.
exclude_sync_ids (opt) — comma-separated SYNC-class ids (snippets
recorded AT the edited file, #2708) already
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.
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
(#2791): when the session recently PULLED a
snippet this payload references or resembles,
these land as instance rows (classified_by=hook).
Honoured only for a caller allowed to write — a
read-scoped key still gets the hint, and never
changes accounting on a GET.
"""
path = (request.args.get("path") or "").strip()
code = request.args.get("code") or ""
try:
project_id = int(request.args.get("project_id", 0) or 0)
except (TypeError, ValueError):
project_id = 0
repo = (request.args.get("repo") or "").strip()
if repo and not project_id:
resolved = await repo_bindings_svc.resolve_project(g.user.id, repo)
if resolved:
project_id = resolved
exclude_ids = [
int(p) for p in (request.args.get("exclude_ids") or "").split(",")
if p.strip().isdigit()
]
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"))
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"
result = await plugin_ctx_svc.build_write_path_hint(
g.user.id, path, code=code, project_id=project_id, exclude_ids=exclude_ids
g.user.id, path, code=code, project_id=project_id,
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 "",
)
return jsonify(result)
# The hook names at most a dozen definitions per write; anything past that is
# a generated file, not a shape being instantiated.
_SHAPES_CAP = 12
def _parse_shapes(raw: str) -> list[tuple[str, str]]:
"""`css:btn-primary,sym:onTrash` → [("css", "btn-primary"), ("sym", "onTrash")].
Unknown kinds and empty names are dropped, duplicates collapse, and the
list is capped — the hook's own cap, re-applied so the contract holds
for any caller."""
out: list[tuple[str, str]] = []
for part in raw.split(","):
kind, _sep, name = part.strip().partition(":")
kind, name = kind.strip(), name.strip()
if kind in ("css", "sym") and name and (kind, name) not in out:
out.append((kind, name))
if len(out) >= _SHAPES_CAP:
break
return out
@plugin_bp.get("/processes")
@login_required
async def process_manifest():
+48 -10
View File
@@ -1,7 +1,7 @@
"""Project management routes."""
import logging
from quart import Blueprint, jsonify, request
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
@@ -123,14 +123,14 @@ async def delete_project_route(project_id: int):
@projects_bp.route("/<int:project_id>/coverage", methods=["GET"])
@login_required
async def get_coverage_route(project_id: int):
"""The cached pattern-library coverage summary — never computes.
"""The cached shape-accounting summary — never computes.
`configured` tells the card whether offering a Refresh button makes
sense; `coverage` is null until something has computed it (a webhook
push or an explicit refresh).
"""
from scribe.services.coverage import cached_coverage
from scribe.services.forge import get_forge
from scribe.services.forge import get_forges
uid = get_current_user_id()
result = await get_project_for_user(uid, project_id)
@@ -139,7 +139,9 @@ async def get_coverage_route(project_id: int):
project, _ = result
owner_uid = project.user_id or uid
return jsonify({
"configured": await get_forge() is not None,
# The OWNER's keyring (#2778) — whether refresh could do anything,
# regardless of who is looking.
"configured": (await get_forges(owner_uid, project_id)).configured,
"coverage": await cached_coverage(owner_uid, project_id),
})
@@ -153,7 +155,7 @@ async def refresh_coverage_route(project_id: int):
and wants the new number, and the forge timeout bounds the wait.
"""
from scribe.services.coverage import refresh_coverage
from scribe.services.forge import ForgeError, get_forge
from scribe.services.forge import ForgeError, get_forges
uid = get_current_user_id()
result = await get_project_for_user(uid, project_id)
@@ -161,20 +163,56 @@ async def refresh_coverage_route(project_id: int):
return not_found("Project")
project, _ = result
owner_uid = project.user_id or uid
if await get_forge() is None:
return jsonify({"error": "No git forge is configured (Settings → Config → Git Forge)"}), 400
selector = await get_forges(owner_uid, project_id)
if not selector.configured:
return jsonify({
"error": "The project owner has no forge connection "
"(Settings → Git forges)"
}), 400
try:
coverage = await refresh_coverage(owner_uid, project_id)
coverage = await refresh_coverage(owner_uid, project_id, selector=selector)
except ForgeError as exc:
return jsonify({"error": str(exc)}), 502
if coverage is None:
return jsonify({
"error": "No bound repo is served by the configured forge"
"bind the project's repo (bind_repo) on a remote the forge hosts"
"error": "No bound repo is served by the owner's forge connections"
"bind the project's repo (bind_repo) on a remote a connection hosts"
}), 400
return jsonify({"coverage": coverage})
@projects_bp.route("/<int:project_id>/forge", methods=["PUT"])
@login_required
async def set_project_forge_route(project_id: int):
"""Pin the project to one forge connection, or clear the pin (#2778).
Body: {"connection_id": <id> | null}. Owner-or-admin may ask; either way
the pin can only reference a connection the project OWNER holds — the
service enforces that, so a collaborator's token can never end up serving
someone else's project.
"""
from scribe.services.forge_connections import set_project_pin
uid = get_current_user_id()
result = await get_project_for_user(uid, project_id)
if result is None:
return not_found("Project")
project, permission = result
if permission != "owner" and g.user.role != "admin":
return jsonify({"error": "Only the project owner can change its forge"}), 403
data = await request.get_json() or {}
raw = data.get("connection_id")
if raw is not None and not isinstance(raw, int):
return jsonify({"error": "connection_id must be an integer or null"}), 400
owner_uid = project.user_id or uid
if not await set_project_pin(owner_uid, project_id, raw):
return jsonify({
"error": "That connection does not belong to the project owner"
}), 400
return jsonify({"forge_connection_id": raw})
@projects_bp.route("/<int:project_id>/notes", methods=["GET"])
@login_required
async def get_project_notes_route(project_id: int):
+25 -28
View File
@@ -1,29 +1,26 @@
"""Rulebook / topic REST endpoints.
Wraps services/rulebooks.py. Standard Scribe auth: g.user.id is the
Wraps services/rulebooks.py. Standard Scribe auth: get_current_user_id() is the
authenticated owner; the service enforces ownership scoping.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from quart import Blueprint, jsonify, request
from scribe.auth import login_required
from scribe.auth import get_current_user_id, login_required
import scribe.services.rulebooks as rulebooks_svc
from scribe.services.trash import delete as trash_delete
rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
def _uid() -> int:
return g.user.id
# ── Rulebooks ───────────────────────────────────────────────────────────
@rulebooks_bp.get("/rulebooks")
@login_required
async def list_rulebooks():
rows = await rulebooks_svc.list_rulebooks(_uid())
rows = await rulebooks_svc.list_rulebooks(get_current_user_id())
return jsonify({"rulebooks": [rb.to_dict() for rb in rows]})
@@ -35,7 +32,7 @@ async def create_rulebook():
if not title:
return jsonify({"error": "title is required"}), 400
rb = await rulebooks_svc.create_rulebook(
user_id=_uid(),
user_id=get_current_user_id(),
title=title,
description=data.get("description", ""),
)
@@ -45,7 +42,7 @@ async def create_rulebook():
@rulebooks_bp.get("/rulebooks/<int:rulebook_id>")
@login_required
async def get_rulebook(rulebook_id: int):
rb = await rulebooks_svc.get_rulebook(rulebook_id, _uid())
rb = await rulebooks_svc.get_rulebook(rulebook_id, get_current_user_id())
if rb is None:
return jsonify({"error": "rulebook not found"}), 404
return jsonify(rb.to_dict())
@@ -56,7 +53,7 @@ async def get_rulebook(rulebook_id: int):
async def update_rulebook(rulebook_id: int):
data = await request.get_json() or {}
fields = {k: v for k, v in data.items() if k in ("title", "description", "always_on")}
rb = await rulebooks_svc.update_rulebook(rulebook_id, _uid(), **fields)
rb = await rulebooks_svc.update_rulebook(rulebook_id, get_current_user_id(), **fields)
if rb is None:
return jsonify({"error": "rulebook not found"}), 404
return jsonify(rb.to_dict())
@@ -65,7 +62,7 @@ async def update_rulebook(rulebook_id: int):
@rulebooks_bp.delete("/rulebooks/<int:rulebook_id>")
@login_required
async def delete_rulebook(rulebook_id: int):
await trash_delete(_uid(), "rulebook", rulebook_id)
await trash_delete(get_current_user_id(), "rulebook", rulebook_id)
return "", 204
@@ -75,7 +72,7 @@ async def delete_rulebook(rulebook_id: int):
@login_required
async def list_topics(rulebook_id: int):
try:
rows = await rulebooks_svc.list_topics(rulebook_id, _uid())
rows = await rulebooks_svc.list_topics(rulebook_id, get_current_user_id())
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify({"topics": [t.to_dict() for t in rows]})
@@ -91,7 +88,7 @@ async def create_topic(rulebook_id: int):
try:
topic = await rulebooks_svc.create_topic(
rulebook_id=rulebook_id,
user_id=_uid(),
user_id=get_current_user_id(),
title=title,
description=data.get("description", ""),
order_index=data.get("order_index", 0),
@@ -109,7 +106,7 @@ async def update_topic(topic_id: int):
k: v for k, v in data.items()
if k in ("title", "description", "order_index")
}
topic = await rulebooks_svc.update_topic(topic_id, _uid(), **fields)
topic = await rulebooks_svc.update_topic(topic_id, get_current_user_id(), **fields)
if topic is None:
return jsonify({"error": "topic not found"}), 404
return jsonify(topic.to_dict())
@@ -118,7 +115,7 @@ async def update_topic(topic_id: int):
@rulebooks_bp.delete("/rulebook-topics/<int:topic_id>")
@login_required
async def delete_topic(topic_id: int):
if await trash_delete(_uid(), "topic", topic_id) is None:
if await trash_delete(get_current_user_id(), "topic", topic_id) is None:
return jsonify({"error": "topic not found"}), 404
return "", 204
@@ -140,7 +137,7 @@ async def list_rules():
return jsonify({"error": "rulebook_id, topic_id, project_id must be integers"}), 400
rows = await rulebooks_svc.list_rules(
user_id=_uid(),
user_id=get_current_user_id(),
rulebook_id=rulebook_id,
topic_id=topic_id,
project_id=project_id,
@@ -159,7 +156,7 @@ async def create_rule(topic_id: int):
try:
rule = await rulebooks_svc.create_rule(
topic_id=topic_id,
user_id=_uid(),
user_id=get_current_user_id(),
title=title,
statement=statement,
why=data.get("why", ""),
@@ -174,7 +171,7 @@ async def create_rule(topic_id: int):
@rulebooks_bp.get("/rules/<int:rule_id>")
@login_required
async def get_rule(rule_id: int):
rule = await rulebooks_svc.get_rule(rule_id, _uid())
rule = await rulebooks_svc.get_rule(rule_id, get_current_user_id())
if rule is None:
return jsonify({"error": "rule not found"}), 404
return jsonify(rule.to_dict())
@@ -188,7 +185,7 @@ async def update_rule(rule_id: int):
k: v for k, v in data.items()
if k in ("title", "statement", "why", "how_to_apply", "order_index")
}
rule = await rulebooks_svc.update_rule(rule_id, _uid(), **fields)
rule = await rulebooks_svc.update_rule(rule_id, get_current_user_id(), **fields)
if rule is None:
return jsonify({"error": "rule not found"}), 404
return jsonify(rule.to_dict())
@@ -197,7 +194,7 @@ async def update_rule(rule_id: int):
@rulebooks_bp.delete("/rules/<int:rule_id>")
@login_required
async def delete_rule(rule_id: int):
if await trash_delete(_uid(), "rule", rule_id) is None:
if await trash_delete(get_current_user_id(), "rule", rule_id) is None:
return jsonify({"error": "rule not found"}), 404
return "", 204
@@ -213,7 +210,7 @@ async def subscribe_project(project_id: int):
return jsonify({"error": "rulebook_id is required"}), 400
try:
await rulebooks_svc.subscribe_project(
project_id=project_id, rulebook_id=int(rulebook_id), user_id=_uid(),
project_id=project_id, rulebook_id=int(rulebook_id), user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
@@ -227,7 +224,7 @@ async def subscribe_project(project_id: int):
async def unsubscribe_project(project_id: int, rulebook_id: int):
try:
await rulebooks_svc.unsubscribe_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=_uid(),
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
@@ -238,7 +235,7 @@ async def unsubscribe_project(project_id: int, rulebook_id: int):
@login_required
async def get_project_rules(project_id: int):
result = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=_uid(),
project_id=project_id, user_id=get_current_user_id(),
)
return jsonify(result)
@@ -248,7 +245,7 @@ async def get_project_rules(project_id: int):
async def suppress_project_rule(project_id: int, rule_id: int):
try:
await rulebooks_svc.suppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=_uid(),
project_id=project_id, rule_id=rule_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
@@ -260,7 +257,7 @@ async def suppress_project_rule(project_id: int, rule_id: int):
async def unsuppress_project_rule(project_id: int, rule_id: int):
try:
await rulebooks_svc.unsuppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=_uid(),
project_id=project_id, rule_id=rule_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
@@ -272,7 +269,7 @@ async def unsuppress_project_rule(project_id: int, rule_id: int):
async def suppress_project_topic(project_id: int, topic_id: int):
try:
await rulebooks_svc.suppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=_uid(),
project_id=project_id, topic_id=topic_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
@@ -284,7 +281,7 @@ async def suppress_project_topic(project_id: int, topic_id: int):
async def unsuppress_project_topic(project_id: int, topic_id: int):
try:
await rulebooks_svc.unsuppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=_uid(),
project_id=project_id, topic_id=topic_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
@@ -303,7 +300,7 @@ async def create_project_rule(project_id: int):
try:
rule = await rulebooks_svc.create_project_rule(
project_id=project_id,
user_id=_uid(),
user_id=get_current_user_id(),
title=title,
statement=statement,
why=data.get("why", ""),
+107 -5
View File
@@ -9,7 +9,9 @@ from quart import Blueprint, jsonify, request
from scribe.auth import login_required, get_current_user_id
from scribe.config import Config
from scribe.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch
from scribe.services.settings import (
SECRET_MASK, delete_setting, get_all_settings, get_setting, set_settings_batch,
)
logger = logging.getLogger(__name__)
@@ -20,13 +22,13 @@ settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
# read and skip the mask on write; this generic KV surface has to apply the
# same treatment, or it silently un-masks what those endpoints masked — the
# rows live on the admin's own user_id, so the plain GET returned them raw.
_SECRET_KEYS = frozenset({"smtp_password", "forge_token", "forge_webhook_secret"})
_SECRET_MASK = "********"
# (forge_token left with 0078: forge credentials are keyring rows now, #2778.)
_SECRET_KEYS = frozenset({"smtp_password", "forge_webhook_secret"})
def _masked(settings: dict) -> dict:
return {
k: (_SECRET_MASK if k in _SECRET_KEYS and v else v)
k: (SECRET_MASK if k in _SECRET_KEYS and v else v)
for k, v in settings.items()
}
@@ -52,7 +54,7 @@ async def update_settings_route():
str_v = str(v)
# A masked secret round-tripping through a client is "unchanged", not
# a request to store the mask over the real credential.
if k in _SECRET_KEYS and str_v == _SECRET_MASK:
if k in _SECRET_KEYS and str_v == SECRET_MASK:
continue
if not str_v:
await delete_setting(uid, k)
@@ -73,3 +75,103 @@ async def test_search():
if not Config.searxng_enabled():
return jsonify({"configured": False, "results": [], "searxng_url": ""})
return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL})
# --- forge connections (#2778) ------------------------------------------------
# The user's keyring: read-only forge credentials, one per host, resolved by
# repo host for every server-side forge read on the user's projects. Strictly
# own-rows — a connection is a credential, and there is no admin view of
# another user's keyring. Tokens never leave the server: the model's to_dict
# omits them, and the routes never echo the submitted value back.
@settings_bp.route("/forge-connections", methods=["GET"])
@login_required
async def list_forge_connections_route():
from scribe.services.forge import FORGE_KINDS
from scribe.services.forge_connections import list_connections
uid = get_current_user_id()
rows = await list_connections(uid)
return jsonify({
"connections": [r.to_dict() for r in rows],
"kinds": list(FORGE_KINDS),
})
@settings_bp.route("/forge-connections", methods=["POST"])
@login_required
async def create_forge_connection_route():
from scribe.services.forge_connections import create_connection
uid = get_current_user_id()
data = await request.get_json() or {}
try:
row = await create_connection(
uid,
kind=str(data.get("kind", "")),
base_url=str(data.get("base_url", "")),
token=str(data.get("token", "")),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(row.to_dict()), 201
@settings_bp.route("/forge-connections/<int:connection_id>", methods=["PUT"])
@login_required
async def update_forge_connection_route(connection_id: int):
from scribe.services.forge_connections import update_connection
uid = get_current_user_id()
data = await request.get_json() or {}
token = str(data.get("token", ""))
# The mask coming back means "unchanged" — the form round-trips what the
# list showed, and storing the mask would silently break the connection.
if token == SECRET_MASK:
token = ""
try:
row = await update_connection(
uid, connection_id,
kind=str(data.get("kind", "")),
base_url=str(data.get("base_url", "")),
token=token,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
if row is None:
return jsonify({"error": "Connection not found"}), 404
return jsonify(row.to_dict())
@settings_bp.route("/forge-connections/<int:connection_id>", methods=["DELETE"])
@login_required
async def delete_forge_connection_route(connection_id: int):
from scribe.services.forge_connections import delete_connection
uid = get_current_user_id()
if not await delete_connection(uid, connection_id):
return jsonify({"error": "Connection not found"}), 404
return "", 204
@settings_bp.route("/forge-connections/<int:connection_id>/test", methods=["POST"])
@login_required
async def test_forge_connection_route(connection_id: int):
"""Probe the SAVED connection: reachability and token acceptance in one
press, so a misconfiguration is visible now rather than as silent
fallbacks later (#2663's lesson, applied per keyring row)."""
from scribe.services.forge import ForgeError, build_adapter
from scribe.services.forge_connections import get_connection
uid = get_current_user_id()
row = await get_connection(uid, connection_id)
if row is None:
return jsonify({"error": "Connection not found"}), 404
adapter = build_adapter(row.kind, row.base_url, row.token)
if adapter is None:
return jsonify({"error": "Connection is not usable — check kind and base URL"}), 400
try:
return jsonify(await adapter.check())
except ForgeError as exc:
return jsonify({"error": str(exc)}), 502
+5 -8
View File
@@ -1,33 +1,30 @@
"""Trash REST API — list / restore / purge soft-deleted content by batch."""
from __future__ import annotations
from quart import Blueprint, g, jsonify
from quart import Blueprint, jsonify
from scribe.auth import login_required
from scribe.auth import get_current_user_id, login_required
import scribe.services.trash as trash_svc
trash_bp = Blueprint("trash", __name__, url_prefix="/api/trash")
def _uid() -> int:
return g.user.id
@trash_bp.get("")
@login_required
async def list_trash():
return jsonify({"batches": await trash_svc.list_trash(_uid())})
return jsonify({"batches": await trash_svc.list_trash(get_current_user_id())})
@trash_bp.post("/<batch_id>/restore")
@login_required
async def restore_batch(batch_id: str):
n = await trash_svc.restore(_uid(), batch_id)
n = await trash_svc.restore(get_current_user_id(), batch_id)
return jsonify({"restored": n})
@trash_bp.delete("/<batch_id>")
@login_required
async def purge_batch(batch_id: str):
n = await trash_svc.purge(_uid(), batch_id)
n = await trash_svc.purge(get_current_user_id(), batch_id)
return jsonify({"purged": n})
+7 -4
View File
@@ -13,8 +13,11 @@ def generate_key() -> str:
return "fmcp_" + secrets.token_urlsafe(32)
def _hash_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
def hash_token(raw: str) -> str:
"""The ONE fingerprint for every bearer secret stored by hash — API keys,
password-reset tokens, invitation tokens. Stored rows hold this, never
the raw value; a lookup hashes the presented token and compares."""
return hashlib.sha256(raw.encode()).hexdigest()
def _key_prefix(key: str) -> str:
@@ -32,7 +35,7 @@ async def create_api_key(
key = ApiKey(
user_id=user_id,
name=name,
key_hash=_hash_key(full_key),
key_hash=hash_token(full_key),
key_prefix=_key_prefix(full_key),
scope=scope,
)
@@ -70,7 +73,7 @@ async def revoke_api_key(user_id: int, key_id: int) -> bool:
async def lookup_key(raw_key: str) -> ApiKey | None:
"""Look up a non-revoked ApiKey by raw token value. Updates last_used_at."""
key_hash = _hash_key(raw_key)
key_hash = hash_token(raw_key)
async with async_session() as session:
result = await session.execute(
select(ApiKey).where(
+13 -25
View File
@@ -1,4 +1,3 @@
import hashlib
import logging
import secrets
from datetime import datetime, timedelta, timezone
@@ -12,6 +11,8 @@ from scribe.models.invitation import InvitationToken
from scribe.models.password_reset import PasswordResetToken
from scribe.models.setting import Setting
from scribe.models.user import User
from scribe.services.api_keys import hash_token
from scribe.services.settings import get_admin_setting
logger = logging.getLogger(__name__)
@@ -142,16 +143,7 @@ async def is_registration_open() -> bool:
user_count = await get_user_count()
if user_count == 0:
return True
async with async_session() as session:
# Find the admin user's registration_open setting
result = await session.execute(
select(Setting)
.join(User, Setting.user_id == User.id)
.where(User.role == "admin", Setting.key == "registration_open")
)
setting = result.scalar_one_or_none()
return setting.value == "true" if setting else False
return await get_admin_setting("registration_open", "false") == "true"
async def list_users() -> list[User]:
@@ -211,7 +203,7 @@ async def get_user_by_email(email: str) -> User | None:
async def create_password_reset_token(user_id: int) -> str:
"""Generate a password reset token. Returns the raw token (for the email link)."""
raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
token_hash = hash_token(raw_token)
expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
async with async_session() as session:
@@ -239,7 +231,7 @@ async def create_password_reset_token(user_id: int) -> str:
async def reset_password_with_token(raw_token: str, new_password: str) -> int | None:
"""Validate a reset token and update the user's password. Returns user_id on success."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
token_hash = hash_token(raw_token)
async with async_session() as session:
result = await session.execute(
@@ -270,7 +262,7 @@ async def reset_password_with_token(raw_token: str, new_password: str) -> int |
async def create_invitation(email: str, invited_by: int) -> str:
"""Generate an invitation token. Returns the raw token (for the email link)."""
raw_token = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
token_hash = hash_token(raw_token)
expires_at = datetime.now(timezone.utc) + timedelta(days=7)
async with async_session() as session:
@@ -299,7 +291,7 @@ async def create_invitation(email: str, invited_by: int) -> str:
async def validate_invitation_token(raw_token: str) -> InvitationToken | None:
"""Look up by hash, check not used/expired. Returns the token record with email."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
token_hash = hash_token(raw_token)
async with async_session() as session:
result = await session.execute(
@@ -319,7 +311,7 @@ async def validate_invitation_token(raw_token: str) -> InvitationToken | None:
async def register_with_invitation(raw_token: str, username: str, password: str) -> User | None:
"""Validate token, create user with the invitation's email, mark token used."""
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
token_hash = hash_token(raw_token)
async with async_session() as session:
result = await session.execute(
@@ -398,20 +390,16 @@ async def purge_expired_auth_tokens(grace_days: int = 7) -> int:
return removed
async def _auth_token_retention_loop() -> None:
import asyncio
while True:
await asyncio.sleep(86400) # daily
try:
async def _auth_token_retention_tick() -> None:
removed = await purge_expired_auth_tokens()
if removed:
logger.info("Auth token retention: deleted %d expired token(s)", removed)
except Exception:
logger.exception("Error in auth token retention cleanup")
def start_auth_token_retention_loop() -> None:
global _auth_retention_task
import asyncio
if _auth_retention_task is None or _auth_retention_task.done():
_auth_retention_task = asyncio.create_task(_auth_token_retention_loop())
from scribe.services.background import start_periodic
_auth_retention_task = start_periodic(
86400, _auth_token_retention_tick, label="auth_token_retention", # daily
)
+20
View File
@@ -45,6 +45,26 @@ def spawn(coro: Coroutine, *, site: str) -> None:
task.add_done_callback(_done)
def start_periodic(interval_s: float, work, *, label: str) -> asyncio.Task:
"""A forever loop that sleeps ``interval_s`` then awaits ``work()``, logging
(never raising) when a tick fails — the one shape the hourly/daily
retention sweeps share (log retention, notification sweep, auth-token
purge). Sleeps FIRST so startup isn't a sweep; holds a strong reference
like spawn() so the loop cannot be garbage-collected mid-flight."""
async def _loop() -> None:
while True:
await asyncio.sleep(interval_s)
try:
await work()
except Exception:
logger.exception("periodic task %s failed", label)
task = asyncio.get_running_loop().create_task(_loop(), name=f"periodic-{label}")
_pending.add(task)
task.add_done_callback(_pending.discard)
return task
async def drain() -> None:
"""Await everything in flight — for tests that need the writes landed."""
while _pending:
+302 -273
View File
@@ -11,6 +11,7 @@ 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.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
from scribe.models.project import Project
from scribe.models.repo_binding import RepoBinding
from scribe.models.rulebook import (
@@ -36,8 +37,16 @@ logger = logging.getLogger(__name__)
# v6 (2026-08) added note_supersessions — and the guard did stop the seventh:
# the table shipped without a backup section and the coverage test failed the
# build, which is the whole reason that list was written.
# v7 (2026-08) added code_shapes — the shape ledger (#2787). Classifications
# are judgment data worth carrying; a restore keeps a judgment only when its
# snippet target survives the id re-mapping, else the row rejoins the todo.
# v8 (2026-08) added code_shape_events — the ledger's history (#2793): what
# was used where, when, and why is not recomputable, so it travels.
# 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.
# Bump when the serialized schema changes.
BACKUP_VERSION = 6
BACKUP_VERSION = 9
# 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
@@ -55,6 +64,8 @@ _BACKED_UP = [
# 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",
]
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
@@ -72,6 +83,12 @@ _NOT_INCLUDED = [
"api_keys", "note_embeddings", "app_logs", "notifications",
"invitation_tokens", "password_reset_tokens", "user_profiles",
"retrieval_logs",
# Sensitive credentials, same reasoning as api_keys: a backup that carries
# forge tokens is a token-exfiltration file. Users re-add connections
# after a restore; the per-project pin (projects.forge_connection_id) is
# deliberately not exported either, so restored projects fall back to
# keyring-by-host resolution — the documented unpinned behavior (#2778).
"forge_connections",
]
@@ -160,6 +177,18 @@ def _usage_event_rows(rows) -> list[dict]:
]
def _code_shape_rows(rows) -> list[dict]:
return [r.to_dict() for r in rows]
def _code_shape_event_rows(rows) -> list[dict]:
return [r.to_dict() for r in rows]
def _code_shape_use_rows(rows) -> list[dict]:
return [r.to_dict() for r in rows]
def _repo_binding_rows(rows) -> list[dict]:
return [
{"user_id": r.user_id, "project_id": r.project_id, "repo_key": r.repo_key}
@@ -167,6 +196,142 @@ def _repo_binding_rows(rows) -> list[dict]:
]
# Row builders for the sections both exporters carry. Pure, like the join-table
# helpers above; the full and per-user exports used to restate every one of
# these comprehensions side by side, and a column added to one and not the
# other is a backup that silently drops it (#2293's shape, one layer down).
def _user_rows(rows) -> list[dict]:
return [
{
"id": u.id, "username": u.username, "email": u.email,
"password_hash": u.password_hash, "oauth_sub": u.oauth_sub,
"role": u.role, "session_version": u.session_version,
"created_at": u.created_at.isoformat(),
}
for u in rows
]
def _project_rows(rows) -> list[dict]:
return [
{
"id": p.id, "user_id": p.user_id, "title": p.title,
"description": p.description, "goal": p.goal, "status": p.status,
"color": p.color,
"created_at": p.created_at.isoformat(),
"updated_at": p.updated_at.isoformat(),
}
for p in rows
]
def _milestone_rows(rows) -> list[dict]:
return [
{
"id": m.id, "user_id": m.user_id, "project_id": m.project_id,
"title": m.title, "description": m.description, "status": m.status,
"order_index": m.order_index,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
for m in rows
]
def _note_rows(rows) -> list[dict]:
return [
{
"id": n.id, "user_id": n.user_id, "title": n.title, "body": n.body,
"tags": n.tags or [], "parent_id": n.parent_id,
"project_id": n.project_id, "milestone_id": n.milestone_id,
"status": n.status, "priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in rows
]
def _task_log_rows(rows) -> list[dict]:
return [
{
"id": tl.id, "user_id": tl.user_id, "task_id": tl.task_id,
"content": tl.content, "duration_minutes": tl.duration_minutes,
"created_at": tl.created_at.isoformat(),
"updated_at": tl.updated_at.isoformat(),
}
for tl in rows
]
def _note_draft_rows(rows) -> list[dict]:
return [
{
"id": nd.id, "user_id": nd.user_id, "note_id": nd.note_id,
"proposed_body": nd.proposed_body, "original_body": nd.original_body,
"instruction": nd.instruction, "scope": nd.scope,
"created_at": nd.created_at.isoformat(),
"updated_at": nd.updated_at.isoformat(),
}
for nd in rows
]
def _note_version_rows(rows) -> list[dict]:
return [
{
"id": nv.id, "user_id": nv.user_id, "note_id": nv.note_id,
"title": nv.title, "body": nv.body, "tags": nv.tags or [],
"pin_kind": nv.pin_kind, "pin_label": nv.pin_label,
"created_at": nv.created_at.isoformat(),
}
for nv in rows
]
def _setting_rows(rows) -> list[dict]:
return [{"user_id": s.user_id, "key": s.key, "value": s.value} for s in rows]
def _rulebook_rows(rows) -> list[dict]:
return [
{
"id": rb.id, "owner_user_id": rb.owner_user_id, "title": rb.title,
"description": rb.description, "always_on": rb.always_on,
"created_at": rb.created_at.isoformat(),
"updated_at": rb.updated_at.isoformat(),
}
for rb in rows
]
def _topic_rows(rows) -> list[dict]:
return [
{
"id": t.id, "rulebook_id": t.rulebook_id, "title": t.title,
"description": t.description, "order_index": t.order_index,
"created_at": t.created_at.isoformat(),
"updated_at": t.updated_at.isoformat(),
}
for t 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,
"created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat(),
}
for r in rows
]
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
@@ -199,6 +364,13 @@ async def export_full_backup() -> dict:
design_tokens = (await session.execute(select(DesignToken))).scalars().all()
usage_events = (await session.execute(select(NoteUsageEvent))).scalars().all()
repo_bindings = (await session.execute(select(RepoBinding))).scalars().all()
code_shapes = (await session.execute(select(CodeShape))).scalars().all()
code_shape_events = (await session.execute(
select(CodeShapeEvent).order_by(CodeShapeEvent.at, CodeShapeEvent.id)
)).scalars().all()
code_shape_uses = (await session.execute(
select(CodeShapeUse).order_by(CodeShapeUse.shape_id, CodeShapeUse.snippet_id)
)).scalars().all()
rulebooks = (await session.execute(select(Rulebook))).scalars().all()
topics = (await session.execute(select(RulebookTopic))).scalars().all()
rules = (await session.execute(select(Rule))).scalars().all()
@@ -221,148 +393,17 @@ async def export_full_backup() -> dict:
"Store it securely and restrict access."
),
"_not_included": _NOT_INCLUDED,
"users": [
{
"id": u.id,
"username": u.username,
"email": u.email,
"password_hash": u.password_hash,
"oauth_sub": u.oauth_sub,
"role": u.role,
"session_version": u.session_version,
"created_at": u.created_at.isoformat(),
}
for u in users
],
"projects": [
{
"id": p.id,
"user_id": p.user_id,
"title": p.title,
"description": p.description,
"goal": p.goal,
"status": p.status,
"color": p.color,
"created_at": p.created_at.isoformat(),
"updated_at": p.updated_at.isoformat(),
}
for p in projects
],
"milestones": [
{
"id": m.id,
"user_id": m.user_id,
"project_id": m.project_id,
"title": m.title,
"description": m.description,
"status": m.status,
"order_index": m.order_index,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
for m in milestones
],
"notes": [
{
"id": n.id,
"user_id": n.user_id,
"title": n.title,
"body": n.body,
"tags": n.tags or [],
"parent_id": n.parent_id,
"project_id": n.project_id,
"milestone_id": n.milestone_id,
"status": n.status,
"priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in notes
],
"task_logs": [
{
"id": tl.id,
"user_id": tl.user_id,
"task_id": tl.task_id,
"content": tl.content,
"duration_minutes": tl.duration_minutes,
"created_at": tl.created_at.isoformat(),
"updated_at": tl.updated_at.isoformat(),
}
for tl in task_logs
],
"note_drafts": [
{
"id": nd.id,
"user_id": nd.user_id,
"note_id": nd.note_id,
"proposed_body": nd.proposed_body,
"original_body": nd.original_body,
"instruction": nd.instruction,
"scope": nd.scope,
"created_at": nd.created_at.isoformat(),
"updated_at": nd.updated_at.isoformat(),
}
for nd in note_drafts
],
"note_versions": [
{
"id": nv.id,
"user_id": nv.user_id,
"note_id": nv.note_id,
"title": nv.title,
"body": nv.body,
"tags": nv.tags or [],
"pin_kind": nv.pin_kind,
"pin_label": nv.pin_label,
"created_at": nv.created_at.isoformat(),
}
for nv in note_versions
],
"settings": [
{"user_id": s.user_id, "key": s.key, "value": s.value}
for s in settings
],
"rulebooks": [
{
"id": rb.id,
"owner_user_id": rb.owner_user_id,
"title": rb.title,
"description": rb.description,
"always_on": rb.always_on,
"created_at": rb.created_at.isoformat(),
"updated_at": rb.updated_at.isoformat(),
}
for rb in rulebooks
],
"rulebook_topics": [
{
"id": t.id,
"rulebook_id": t.rulebook_id,
"title": t.title,
"description": t.description,
"order_index": t.order_index,
"created_at": t.created_at.isoformat(),
"updated_at": t.updated_at.isoformat(),
}
for t in topics
],
"rules": [
{
"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,
"created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat(),
}
for r in rules
],
"users": _user_rows(users),
"projects": _project_rows(projects),
"milestones": _milestone_rows(milestones),
"notes": _note_rows(notes),
"task_logs": _task_log_rows(task_logs),
"note_drafts": _note_draft_rows(note_drafts),
"note_versions": _note_version_rows(note_versions),
"settings": _setting_rows(settings),
"rulebooks": _rulebook_rows(rulebooks),
"rulebook_topics": _topic_rows(topics),
"rules": _rule_rows(rules),
"rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
@@ -373,6 +414,9 @@ async def export_full_backup() -> dict:
"note_usage_events": _usage_event_rows(usage_events),
"repo_bindings": _repo_binding_rows(repo_bindings),
"note_supersessions": _note_supersession_rows(supersessions),
"code_shapes": _code_shape_rows(code_shapes),
"code_shape_events": _code_shape_event_rows(code_shape_events),
"code_shape_uses": _code_shape_use_rows(code_shape_uses),
}
@@ -440,6 +484,20 @@ async def export_user_backup(user_id: int) -> dict:
repo_bindings = (await session.execute(
select(RepoBinding).where(RepoBinding.user_id == user_id)
)).scalars().all()
# The ledger has no user_id of its own — rows belong to the project
# they account for, so a user's export carries their projects' rows.
code_shapes = (await session.execute(
select(CodeShape).where(CodeShape.project_id.in_(project_ids))
)).scalars().all() if project_ids else []
code_shape_events = (await session.execute(
select(CodeShapeEvent).where(CodeShapeEvent.project_id.in_(project_ids))
.order_by(CodeShapeEvent.at, CodeShapeEvent.id)
)).scalars().all() if project_ids else []
code_shape_uses = (await session.execute(
select(CodeShapeUse).join(CodeShape, CodeShape.id == CodeShapeUse.shape_id)
.where(CodeShape.project_id.in_(project_ids))
.order_by(CodeShapeUse.shape_id, CodeShapeUse.snippet_id)
)).scalars().all() if project_ids else []
rulebooks = (await session.execute(
select(Rulebook).where(Rulebook.owner_user_id == user_id)
)).scalars().all()
@@ -489,135 +547,16 @@ async def export_user_backup(user_id: int) -> dict:
"role": user.role,
"created_at": user.created_at.isoformat(),
} if user else None,
"projects": [
{
"id": p.id,
"user_id": p.user_id,
"title": p.title,
"description": p.description,
"goal": p.goal,
"status": p.status,
"color": p.color,
"created_at": p.created_at.isoformat(),
"updated_at": p.updated_at.isoformat(),
}
for p in projects
],
"milestones": [
{
"id": m.id,
"user_id": m.user_id,
"project_id": m.project_id,
"title": m.title,
"description": m.description,
"status": m.status,
"order_index": m.order_index,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
for m in milestones
],
"notes": [
{
"id": n.id,
"user_id": n.user_id,
"title": n.title,
"body": n.body,
"tags": n.tags or [],
"parent_id": n.parent_id,
"project_id": n.project_id,
"milestone_id": n.milestone_id,
"status": n.status,
"priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in notes
],
"task_logs": [
{
"id": tl.id,
"user_id": tl.user_id,
"task_id": tl.task_id,
"content": tl.content,
"duration_minutes": tl.duration_minutes,
"created_at": tl.created_at.isoformat(),
"updated_at": tl.updated_at.isoformat(),
}
for tl in task_logs
],
"note_drafts": [
{
"id": nd.id,
"user_id": nd.user_id,
"note_id": nd.note_id,
"proposed_body": nd.proposed_body,
"original_body": nd.original_body,
"instruction": nd.instruction,
"scope": nd.scope,
"created_at": nd.created_at.isoformat(),
"updated_at": nd.updated_at.isoformat(),
}
for nd in note_drafts
],
"note_versions": [
{
"id": nv.id,
"user_id": nv.user_id,
"note_id": nv.note_id,
"title": nv.title,
"body": nv.body,
"tags": nv.tags or [],
"pin_kind": nv.pin_kind,
"pin_label": nv.pin_label,
"created_at": nv.created_at.isoformat(),
}
for nv in note_versions
],
"settings": [
{"user_id": s.user_id, "key": s.key, "value": s.value}
for s in settings
],
"rulebooks": [
{
"id": rb.id,
"owner_user_id": rb.owner_user_id,
"title": rb.title,
"description": rb.description,
"always_on": rb.always_on,
"created_at": rb.created_at.isoformat(),
"updated_at": rb.updated_at.isoformat(),
}
for rb in rulebooks
],
"rulebook_topics": [
{
"id": t.id,
"rulebook_id": t.rulebook_id,
"title": t.title,
"description": t.description,
"order_index": t.order_index,
"created_at": t.created_at.isoformat(),
"updated_at": t.updated_at.isoformat(),
}
for t in topics
],
"rules": [
{
"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,
"created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat(),
}
for r in rules
],
"projects": _project_rows(projects),
"milestones": _milestone_rows(milestones),
"notes": _note_rows(notes),
"task_logs": _task_log_rows(task_logs),
"note_drafts": _note_draft_rows(note_drafts),
"note_versions": _note_version_rows(note_versions),
"settings": _setting_rows(settings),
"rulebooks": _rulebook_rows(rulebooks),
"rulebook_topics": _topic_rows(topics),
"rules": _rule_rows(rules),
"rulebook_subscriptions": _subscription_rows(subscriptions),
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
@@ -628,6 +567,9 @@ async def export_user_backup(user_id: int) -> dict:
"note_usage_events": _usage_event_rows(usage_events),
"repo_bindings": _repo_binding_rows(repo_bindings),
"note_supersessions": _note_supersession_rows(supersessions),
"code_shapes": _code_shape_rows(code_shapes),
"code_shape_events": _code_shape_event_rows(code_shape_events),
"code_shape_uses": _code_shape_use_rows(code_shape_uses),
}
@@ -731,7 +673,8 @@ async def _restore_v2(data: dict) -> dict:
"topic_suppressions": 0,
"systems": 0, "record_systems": 0, "design_systems": 0,
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
"note_supersessions": 0,
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
"code_shape_uses": 0,
}
async with async_session() as session:
@@ -1108,6 +1051,92 @@ async def _restore_v2(data: dict) -> dict:
))
stats["repo_bindings"] += 1
# 21. Code shapes (v7, #2787) — the ledger's classifications are
# judgments worth carrying. A judgment whose snippet target didn't
# survive the re-mapping (canonical/instance/variant with a gone
# snippet) is downgraded to unclassified so it rejoins the todo
# honestly instead of dangling; exempt needs no target and keeps.
shape_id_map: dict[int, int] = {}
for cs_data in data.get("code_shapes", []):
mapped_pid = project_id_map.get(cs_data.get("project_id", 0))
if mapped_pid is None:
continue
status = cs_data.get("status", "unclassified")
mapped_sid = note_id_map.get(cs_data.get("snippet_id") or 0)
classified_by = cs_data.get("classified_by")
classified_at = cs_data.get("classified_at")
if status in ("canonical", "instance", "variant") and mapped_sid is None:
status = "unclassified"
classified_by = None
classified_at = None
shape = CodeShape(
project_id=mapped_pid,
repo_key=cs_data.get("repo_key", ""),
path=cs_data.get("path", ""),
symbol=cs_data.get("symbol", ""),
kind=cs_data.get("kind", "sym"),
status=status,
snippet_id=mapped_sid,
reason=cs_data.get("reason"),
classified_by=classified_by,
classified_at=_dt(classified_at) if classified_at else None,
first_seen_commit=cs_data.get("first_seen_commit", ""),
last_seen_commit=cs_data.get("last_seen_commit", ""),
vanished_at=_dt(cs_data["vanished_at"]) if cs_data.get("vanished_at") else None,
# Fingerprints restore; proposals (#2792) deliberately do not —
# they are mechanical, and the next refresh recomputes them
# against the restored snippet ids.
signature=cs_data.get("signature", ""),
body_sha=cs_data.get("body_sha", ""),
classified_sha=cs_data.get("classified_sha", ""),
created_at=_dt(cs_data.get("created_at")),
updated_at=_dt(cs_data.get("updated_at")),
)
session.add(shape)
await session.flush()
if cs_data.get("id"):
shape_id_map[int(cs_data["id"])] = shape.id
stats["code_shapes"] += 1
# v8: the ledger's history rides its shapes. snippet_id is kept as
# the history's own claim (FK-free by design) but re-mapped when the
# snippet survived, so a restored timeline points at restored records.
for ev in data.get("code_shape_events", []):
new_shape_id = shape_id_map.get(ev.get("shape_id") or 0)
mapped_pid = project_id_map.get(ev.get("project_id", 0))
if new_shape_id is None or mapped_pid is None:
continue
old_sid = ev.get("snippet_id")
session.add(CodeShapeEvent(
shape_id=new_shape_id,
project_id=mapped_pid,
path=ev.get("path", ""),
symbol=ev.get("symbol", ""),
kind=ev.get("kind", "sym"),
event=ev.get("event", "classified"),
status=ev.get("status"),
snippet_id=note_id_map.get(old_sid, old_sid) if old_sid else None,
classified_by=ev.get("classified_by"),
reason=ev.get("reason"),
commit=ev.get("commit", ""),
at=_dt(ev.get("at")),
))
stats["code_shape_events"] += 1
# v9: consumption edges (#2870) ride their shape AND their snippet —
# both ends must have survived, or the edge is no longer a fact.
for use in data.get("code_shape_uses", []):
new_shape_id = shape_id_map.get(use.get("shape_id") or 0)
new_sid = note_id_map.get(use.get("snippet_id") or 0)
if new_shape_id is None or new_sid is None:
continue
session.add(CodeShapeUse(
shape_id=new_shape_id, snippet_id=new_sid,
basis=use.get("basis", "import"), evidence=use.get("evidence"),
created_at=_dt(use.get("created_at")),
))
stats["code_shape_uses"] += 1
await session.commit()
logger.info("Restored v2/v3 backup: %s", stats)
+451 -123
View File
@@ -25,23 +25,28 @@ only ever reads the cache.
"""
from __future__ import annotations
import hashlib
import io
import json
import logging
import posixpath
import re
import tarfile
from datetime import datetime, timezone
from typing import NamedTuple
from datetime import datetime, timedelta, timezone
from scribe.services.forge import ForgeAdapter, get_forge
from scribe.services.repo_bindings import keys_for_project
from scribe.services.forge import ForgeSelector, get_forges
from scribe.services.repo_bindings import bindings_for_project
from scribe.services.settings import get_setting, set_setting
logger = logging.getLogger(__name__)
# Cache key in the settings KV, on the project OWNER's user_id — the same
# channel the scheduler's last-run summary uses for machine-written state.
_CACHE_KEY_PREFIX = "pattern_coverage_"
# v2 suffix with #2788: the payload shape inverted (accounted/unclassified);
# pre-ledger blobs under the old key simply stop being found, so the card
# honestly reads "not measured yet" until the first ledger-era refresh.
_CACHE_KEY_PREFIX = "pattern_coverage_v2_"
# Files whose content can't hold definitions — the hook's skip list, verbatim,
# plus sourcemaps (which are JSON in a trenchcoat).
@@ -88,6 +93,148 @@ _ARROW_RE = re.compile(
)
class Definition(NamedTuple):
"""One extracted definition with its content fingerprint (#2792).
`signature` is the definition line itself; `body_sha` hashes the block
whitespace- and comment-insensitively; `body` is the block's text, held
only for the duration of a refresh (the proposer matches on it) and
never stored.
"""
kind: str
name: str
signature: str
body_sha: str
body: str
line: int = -1 # 0-based line the definition starts on (#2869)
def _definition_on(raw: str) -> tuple[str, str] | None:
"""The (kind, name) this one line defines, or None. First match wins —
the same order the hook's awk program tries."""
m = _CSS_RE.match(raw)
if m:
return ("css", m.group(1))
line = _MODIFIERS_RE.sub("", raw.lstrip())
if m := _GO_METHOD_RE.match(line):
return ("sym", m.group(1))
if m := _KEYWORD_RE.match(line):
name = m.group(1)
if name.startswith("__") and name.endswith("__"):
return None
return ("sym", name)
if m := _ARROW_RE.match(line):
return ("sym", m.group(1))
return None
# A definition's block runs from its line until the next non-blank line at
# its own indentation or shallower that is not a closer — so a Python def ends
# at the next top-level statement, a braces block keeps its `}`, a CSS rule
# keeps its `}`. Capped so a generated monolith can't make one shape's
# fingerprint cover the file.
_BLOCK_CAP = 120
_CLOSERS = ("}", ")", "]", "end", "};", "});", ");", "})", "]);")
# Lines that don't change what a shape IS: comments and decorators. Dropped
# from the fingerprint so touching a comment above the next function doesn't
# read as this one's body changing.
_NOISE_PREFIXES = ("#", "//", "/*", "*", "*/", "@", "<!--", "-->")
_SIGNATURE_CAP = 300
def _indent(line: str) -> int:
return len(line) - len(line.lstrip())
def _block_sha(lines: list[str]) -> str:
kept = [
" ".join(ln.split())
for ln in lines
if ln.strip() and not ln.lstrip().startswith(_NOISE_PREFIXES)
]
return hashlib.sha1("\n".join(kept).encode("utf-8")).hexdigest()[:16]
def extract_definitions(text: str) -> list[Definition]:
"""Every definition this text makes, with signature + fingerprint.
Duplicate (kind, name) within one text collapse to the first — the
ledger's identity is per file, so a second definition of the same name
(an overload, a re-declaration) is the same shape to it.
"""
lines = text.splitlines()
starts: list[tuple[int, str, str]] = []
for i, raw in enumerate(lines):
hit = _definition_on(raw)
if hit:
starts.append((i, hit[0], hit[1]))
seen: set[tuple[str, str]] = set()
out: list[Definition] = []
for i, kind, name in starts:
if (kind, name) in seen:
continue
seen.add((kind, name))
base = _indent(lines[i])
end = min(len(lines), i + _BLOCK_CAP)
for j in range(i + 1, min(len(lines), i + _BLOCK_CAP)):
ln = lines[j]
if not ln.strip():
continue
if _indent(ln) <= base and ln.strip() not in _CLOSERS:
end = j
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.
hashed = block[1:] if kind == "css" and len(block) > 1 else block
out.append(Definition(
kind, name, lines[i].strip()[:_SIGNATURE_CAP], _block_sha(hashed),
"\n".join(block), i,
))
return out
# --- by-construction scope (#2869) -------------------------------------------
#
# A Vue single-file component's `<style scoped>` rules and its `<script setup>`
# functions cannot be reached from any other file: they are one-offs by
# construction, not by judgment. The sync stamps them `scoped` (mechanical) so
# the human todo holds only shapes a person should look at, while the bodies
# stay in play for the proposer, derive grouping and divergence — the five
# auth views' identical rules were found exactly there. Unscoped `<style>` in
# a .vue and every non-.vue file stay ordinary.
_STYLE_OPEN_RE = re.compile(r"^\s*<style\b[^>]*\bscoped\b", re.IGNORECASE)
_STYLE_CLOSE_RE = re.compile(r"^\s*</style\s*>", re.IGNORECASE)
def scoped_definitions(path: str, text: str, defs: list[Definition]) -> set[tuple[str, str]]:
"""The (kind, name) pairs among ``defs`` that are one-offs by
construction in this file: every sym in a .vue, and every css rule
that starts inside a `<style scoped>` block. Empty for other files."""
if not (path or "").lower().endswith(".vue"):
return set()
ranges: list[tuple[int, int]] = []
open_at: int | None = None
for i, ln in enumerate(text.splitlines()):
if open_at is None and _STYLE_OPEN_RE.match(ln):
open_at = i
elif open_at is not None and _STYLE_CLOSE_RE.match(ln):
ranges.append((open_at, i))
open_at = None
out: set[tuple[str, str]] = set()
for d in defs:
if d.kind == "sym":
out.add((d.kind, d.name))
elif any(a <= d.line <= b for a, b in ranges):
out.add((d.kind, d.name))
return out
def extract_shapes(text: str) -> list[tuple[str, str]]:
"""Every (kind, name) this text DEFINES — kind is "css" or "sym".
@@ -95,29 +242,7 @@ def extract_shapes(text: str) -> list[tuple[str, str]]:
line, dunders are skipped (every class defines __init__ — guaranteed
noise), duplicates within one text count once.
"""
seen: set[tuple[str, str]] = set()
out: list[tuple[str, str]] = []
for raw in text.splitlines():
m = _CSS_RE.match(raw)
if m:
shape = ("css", m.group(1))
else:
line = _MODIFIERS_RE.sub("", raw.lstrip())
if m := _GO_METHOD_RE.match(line):
shape = ("sym", m.group(1))
elif m := _KEYWORD_RE.match(line):
name = m.group(1)
if name.startswith("__") and name.endswith("__"):
continue
shape = ("sym", name)
elif m := _ARROW_RE.match(line):
shape = ("sym", m.group(1))
else:
continue
if shape not in seen:
seen.add(shape)
out.append(shape)
return out
return [(d.kind, d.name) for d in extract_definitions(text)]
def scannable(path: str) -> bool:
@@ -128,14 +253,34 @@ def scannable(path: str) -> bool:
return not path.lower().endswith(_SKIP_SUFFIXES)
class ArchiveShape(NamedTuple):
"""A definition located in a repo archive — what the sync upserts and
the proposer matches. The leading (path, kind, name) triple is the
ledger identity; the rest is the fingerprint and the transient body."""
path: str
kind: str
name: str
signature: str
body_sha: str
body: str
scoped: bool = False # one-off by construction (#2869)
def shapes_from_archive(blob: bytes) -> list[tuple[str, str, str]]:
"""(path, kind, name) for every definition in a repo tarball.
"""(path, kind, name) for every definition in a repo tarball — the
identity view of definitions_from_archive."""
return [(d.path, d.kind, d.name) for d in definitions_from_archive(blob)]
def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
"""Every definition in a repo tarball, with its fingerprint and body.
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[tuple[str, str, str]] = []
shapes: list[ArchiveShape] = []
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:
@@ -150,84 +295,57 @@ def shapes_from_archive(blob: bytes) -> list[tuple[str, str, str]]:
text = handle.read().decode("utf-8")
except UnicodeDecodeError:
continue
shapes.extend((path, kind, name) for kind, name in extract_shapes(text))
defs = extract_definitions(text)
scoped = scoped_definitions(path, text, defs)
shapes.extend(
ArchiveShape(
path, d.kind, d.name, d.signature, d.body_sha, d.body,
(d.kind, d.name) in scoped,
)
for d in defs
)
return shapes
# --- matching shapes against recorded locations ------------------------------
def _norm_symbol(kind_or_symbol: str) -> str:
# CSS shapes and recorded CSS symbols may or may not carry the leading
# dot; compare without it so ".btn-primary" and "btn-primary" agree.
return kind_or_symbol.lstrip(".").strip()
def _location_covers(loc_path: str, loc_symbol: str, path: str, name: str) -> bool:
if _norm_symbol(loc_symbol) != _norm_symbol(name):
return False
if not loc_path:
# Symbol-only record: the symbol match is all the claim there is.
return True
# The drift check's location semantics, not a second copy of them: exact
# file, or the recorded path is a directory the file lives under.
from scribe.services.snippets import _path_touches
return _path_touches(loc_path, path)
def match_shapes(
shapes: list[tuple[str, str, str]],
recorded: list[tuple[str, str]],
) -> list[tuple[str, str, str, bool]]:
"""Each shape with whether some recorded (path, symbol) location covers it.
Symbol-less recorded locations never cover a shape — a whole-file record
makes no claim about any particular definition inside it. The recorded
repo NAME is deliberately not consulted: it is free-form ("Scribe") and
the project binding already did the scoping; on a project binding several
repos this can over-credit a same-named symbol, which the estimate label
owns.
"""
usable = [(p, s) for p, s in recorded if (s or "").strip()]
return [
(
path,
kind,
name,
any(_location_covers(lp, ls, path, name) for lp, ls in usable),
)
for path, kind, name in shapes
]
# The covering predicate (location_covers) lives in services/shape_ledger.py
# since #2788 — the ledger's canonical marking and this module's readout are
# two consumers of ONE doctrine, and the ledger is its home.
def largest_gaps(
matched: list[tuple[str, str, str, bool]], *, top: int = 3
accounted: list[tuple[str, str, str, bool]], *, top: int = 3
) -> list[dict]:
"""The directories with the most uncovered shapes — where a backlog
session should start, named the way the repo names them."""
"""The directories with the most unclassified shapes — where a
classification session should start, named the way the repo names them."""
by_dir: dict[str, dict[str, int]] = {}
for path, _kind, _name, covered in matched:
for path, _kind, _name, is_accounted in accounted:
d = posixpath.dirname(path) or "(root)"
row = by_dir.setdefault(d, {"total": 0, "uncovered": 0})
row = by_dir.setdefault(d, {"total": 0, "unclassified": 0})
row["total"] += 1
if not covered:
row["uncovered"] += 1
if not is_accounted:
row["unclassified"] += 1
ranked = sorted(
by_dir.items(), key=lambda kv: (-kv[1]["uncovered"], kv[0])
by_dir.items(), key=lambda kv: (-kv[1]["unclassified"], kv[0])
)
return [
{"dir": d, "uncovered": row["uncovered"], "total": row["total"]}
{"dir": d, "unclassified": row["unclassified"], "total": row["total"]}
for d, row in ranked[:top]
if row["uncovered"]
if row["unclassified"]
]
# --- compute, cache, surface -------------------------------------------------
async def _recorded_locations(user_id: int, project_id: int) -> list[tuple[str, str]]:
"""(path, symbol) for every location of every live snippet in a project."""
async def _recorded_locations(
user_id: int, project_id: int
) -> list[tuple[int, str, str]]:
"""(snippet_note_id, path, symbol) for every location of every live
snippet in a project — the canonical-marking input (#2788): the id is what
lets a ledger row point back at the snippet it references."""
from sqlalchemy import select
from scribe.models import async_session
@@ -244,65 +362,160 @@ async def _recorded_locations(user_id: int, project_id: int) -> list[tuple[str,
)
)
notes = list(rows.scalars().all())
out: list[tuple[str, str]] = []
out: list[tuple[int, str, str]] = []
for note in notes:
for loc in snippet_fields(note).get("locations") or []:
out.append((loc.get("path") or "", loc.get("symbol") or ""))
out.append(
(int(note.id), loc.get("path") or "", loc.get("symbol") or "")
)
return out
async def compute_coverage(
user_id: int, project_id: int, *, forge: ForgeAdapter | None = None
user_id: int, project_id: int, *, selector: ForgeSelector | None = None
) -> dict | None:
"""Measure a project's pattern-library coverage against its bound repos.
"""Sync the shape ledger from the bound repos and read the accounting.
None means "nothing to measure" — no forge configured, or none of the
project's bound repos is served by it. That is the ordinary state for a
forge-less install and every caller treats it as silence, not failure.
Forge errors (unreachable, bad token) RAISE — the two callers are a
refresh button and a background task, and both want to know.
Since #2788 this is the ledger's sync point, not just a measurement:
every walk upserts the extracted shapes (new → unclassified, vanished →
stamped), re-stamps snippet reference locations as canonical, and then
reports the ACCOUNTING — how many shapes carry a classification at all —
rather than the old "has a snippet" fraction. Unclassified is the todo
(note 2786).
None means "nothing to measure" — the owner's keyring serves none of the
project's bound repos (#2778). That is the ordinary state for a
forge-less user and every caller treats it as silence, not failure; the
ledger is untouched in that case. Forge errors (unreachable, bad token)
RAISE — the two callers are a refresh button and a background task, and
both want to know.
``user_id`` is the project OWNER's id: the cache lives there, and the
keyring resolved here must be the same one every other read uses.
"""
forge = forge if forge is not None else await get_forge()
if forge is None:
from scribe.services import shape_ledger
from scribe.services.forge import ForgeError
if selector is None:
selector = await get_forges(user_id, project_id)
if not selector.configured:
return None
repos: list[dict] = []
matched_all: list[tuple[str, str, str, bool]] = []
served: list[tuple[str, str]] = []
recorded = await _recorded_locations(user_id, project_id)
for key in await keys_for_project(user_id, project_id):
api_repo = forge.resolve_repo(key)
if api_repo is None:
continue # bound to a host this forge doesn't serve
ref = await forge.default_branch(api_repo)
shapes = shapes_from_archive(await forge.archive(api_repo, ref))
matched = match_shapes(shapes, recorded)
matched_all.extend(matched)
repos.append({
"repo": key,
"ref": ref,
"total": len(matched),
"recorded": sum(1 for *_x, covered in matched if covered),
})
if not repos:
# The proposer's canon catalog, read once per refresh and shared across
# the project's repos (#2792).
canons = None
proposer_stats = {"examined": 0, "proposed": 0, "semantic_checked": 0}
for binding in await bindings_for_project(user_id, project_id):
key = binding.repo_key
hit = selector.resolve(key)
if hit is None:
continue # bound to a host no connection serves
forge, api_repo = hit
# 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))
# 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.
try:
marker = await forge.latest_commit(api_repo, "", ref) or ref
except ForgeError:
marker = ref
await shape_ledger.sync_repo_shapes(
project_id, key, definitions, seen_marker=marker
)
served.append((key, ref))
# 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
# is immaterial; the proposer must not be able to fail the refresh.
try:
if canons is None:
canons = await shape_ledger.canon_catalog(user_id)
stats = await shape_ledger.propose_for_repo(
user_id, project_id, key, definitions, canons=canons
)
for k in proposer_stats:
proposer_stats[k] += stats.get(k, 0)
except Exception:
logger.warning("shape proposer failed for %s", key, exc_info=True)
if not served:
return None
await shape_ledger.mark_canonicals(project_id, recorded)
try:
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).
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
await shape_ledger.flag_divergence(project_id, since=since)
except Exception:
logger.warning("divergence pass failed", exc_info=True)
# Project-wide readout, deliberately wider than this walk: a second bound
# repo that was unreachable today still has live rows, and they count.
rows = await shape_ledger.live_rows(project_id)
counts = {"canonical": 0, "instance": 0, "variant": 0, "exempt": 0,
"scoped": 0, "unclassified": 0}
for row in rows:
counts[row.status] = counts.get(row.status, 0) + 1
by_repo: dict[str, dict[str, int]] = {}
for row in rows:
agg = by_repo.setdefault(row.repo_key, {"total": 0, "accounted": 0})
agg["total"] += 1
agg["accounted"] += row.status != "unclassified"
unclassified = counts.pop("unclassified")
proposals = shape_ledger.proposal_summary(rows)
divergence = shape_ledger.divergence_summary(rows)
return {
"total": len(matched_all),
"recorded": sum(1 for *_x, covered in matched_all if covered),
"total": len(rows),
"accounted": len(rows) - unclassified,
"unclassified": unclassified,
"counts": counts,
# The proposer's standing (#2792): canon proposals awaiting a
# confirm, the largest derive-first groups, and what this refresh did.
"proposed": proposals["proposed"],
"derive_groups": proposals["derive_groups"],
"top_canon": proposals.get("top_canon"),
"proposer": proposer_stats,
# The divergence readout (#2793): button B where button A is canon,
# and judged shapes whose bodies moved since they were judged.
"divergent": divergence["divergent"],
"divergence": divergence["divergence"],
"recheck": divergence["recheck"],
# Honesty flag, not decoration: every surface that shows the number
# is expected to carry it through.
"estimate": True,
"computed_at": datetime.now(timezone.utc).isoformat(),
"repos": repos,
"largest_gaps": largest_gaps(matched_all),
"repos": [
{"repo": key, "ref": ref,
**by_repo.get(key, {"total": 0, "accounted": 0})}
for key, ref in served
],
"largest_gaps": largest_gaps([
(r.path, r.kind, r.symbol, r.status != "unclassified")
for r in rows
]),
}
async def refresh_coverage(
user_id: int, project_id: int, *, forge: ForgeAdapter | None = None
user_id: int, project_id: int, *, selector: ForgeSelector | None = None
) -> dict | None:
"""Compute and cache. The only writer of the cache key."""
coverage = await compute_coverage(user_id, project_id, forge=forge)
coverage = await compute_coverage(user_id, project_id, selector=selector)
if coverage is not None:
await set_setting(
user_id, f"{_CACHE_KEY_PREFIX}{project_id}", json.dumps(coverage)
@@ -310,6 +523,90 @@ async def refresh_coverage(
return coverage
# The arrival-moment self-seed (#2802). A ledger that has never been computed
# used to wait for someone to find the UI Refresh button; entering the project
# is the moment the number is wanted, so entering is the moment it seeds.
_SEED_MAX_AGE = timedelta(hours=24)
# Projects with a refresh already running — concurrent enters must not fetch
# the same tarball N times. In-process on purpose: the cost being bounded is
# per-process forge traffic, and a rare double-fetch across workers is
# harmless (the upsert is idempotent).
_inflight_seed: set[int] = set()
async def refresh_if_stale(
user_id: int, project_id: int, cached: dict | None = None
) -> None:
"""Background-refresh a project's ledger when its readout is absent or
older than a day. Fire-and-forget material (background.spawn): every exit
is quiet, every failure a WARNING — a seed must never surface as an
enter_project error. ``user_id`` is the project OWNER (whose keyring and
cache this is); pass ``cached`` when the caller already read it.
"""
try:
if cached is None:
cached = await cached_coverage(user_id, project_id)
if cached:
try:
computed = datetime.fromisoformat(cached.get("computed_at") or "")
if datetime.now(timezone.utc) - computed < _SEED_MAX_AGE:
return
except ValueError:
pass # an unreadable stamp reads as stale
if project_id in _inflight_seed:
return
selector = await get_forges(user_id, project_id)
if not selector.configured:
return # rule #115: forge-less stays exactly as it was
_inflight_seed.add(project_id)
try:
await refresh_coverage(user_id, project_id, selector=selector)
finally:
_inflight_seed.discard(project_id)
except Exception:
logger.warning(
"background coverage seed failed for project %s", project_id,
exc_info=True,
)
async def refresh_for_caller(caller_id: int, project_id: int) -> dict:
"""The explicit agent-facing refresh (#2802) — synchronous, named errors.
Write-gated: recomputing spends the owner's forge API budget and rewrites
ledger rows' seen-markers, so a read share doesn't grant it. Resolution
runs on the OWNER's keyring like every other forge read. Raises
ValueError with a fixable message instead of silently measuring nothing —
the caller is an agent mid-task, and "None" would strand it exactly the
way the button-only path did.
"""
from scribe.models import async_session
from scribe.models.project import Project
from scribe.services import access
if not await access.can_write_project(caller_id, project_id):
raise ValueError(f"project {project_id} not found or no write access")
async with async_session() as session:
project = await session.get(Project, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
owner_id = project.user_id or caller_id
selector = await get_forges(owner_id, project_id)
if not selector.configured:
raise ValueError(
"No forge connection serves this project — the owner adds one "
"under Settings → Integrations → Git Forges"
)
coverage = await refresh_coverage(owner_id, project_id, selector=selector)
if coverage is None:
raise ValueError(
"No bound repo is served by the owner's forge connections — "
"bind_repo the project's repo on a host a connection serves"
)
return coverage
async def cached_coverage(user_id: int, project_id: int) -> dict | None:
"""The last computed summary, or None — never computes."""
raw = await get_setting(user_id, f"{_CACHE_KEY_PREFIX}{project_id}", "")
@@ -325,12 +622,43 @@ async def cached_coverage(user_id: int, project_id: int) -> dict | None:
def coverage_line(coverage: dict) -> str:
"""The one-line evidence-carrying summary enter_project surfaces."""
day = (coverage.get("computed_at") or "")[:10]
line = (
f"pattern-library coverage: {coverage.get('recorded', 0)}"
f"/{coverage.get('total', 0)} shapes recorded"
f" (estimate{', computed ' + day if day else ''})"
counts = coverage.get("counts") or {}
breakdown = " · ".join(
f"{counts[k]} {k}"
for k in ("canonical", "instance", "variant", "exempt", "scoped")
if counts.get(k)
)
line = (
f"shape accounting: {coverage.get('accounted', 0)}"
f"/{coverage.get('total', 0)} shapes accounted for"
)
if breakdown:
line += f"{breakdown}"
line += f" (estimate{', computed ' + day if day else ''})"
unclassified = coverage.get("unclassified", 0)
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 gaps: " + ", ".join(gaps)
line += ", largest: " + ", ".join(gaps)
if coverage.get("recheck"):
line += f"; {coverage['recheck']} judged shape{'s' if coverage['recheck'] != 1 else ''} changed since judged — recheck"
return line
+2 -1
View File
@@ -15,6 +15,7 @@ from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.milestone import Milestone
from scribe.models.base import iso
from scribe.services import milestones as milestones_svc
logger = logging.getLogger(__name__)
@@ -173,7 +174,7 @@ async def _recently_completed(user_id: int) -> list[dict]:
.order_by(Note.completed_at.desc()).limit(RECENT_DONE_LIMIT)
)).all()
return [{"id": n.id, "title": n.title, "project_title": ptitle,
"completed_at": n.completed_at.isoformat()} for n, ptitle in rows]
"completed_at": iso(n.completed_at)} for n, ptitle in rows]
async def _week_stats(user_id: int) -> dict:
+3 -6
View File
@@ -21,6 +21,7 @@ from datetime import datetime, timezone
from sqlalchemy import text
from scribe.models import async_session, engine
from scribe.models.base import iso
from scribe.services.settings import get_admin_setting, set_admin_setting
logger = logging.getLogger(__name__)
@@ -128,10 +129,6 @@ _HEALTH_SQL = text("""
""")
def _iso(value) -> str | None:
return value.isoformat() if value is not None else None
async def get_table_health() -> dict:
"""Per-table health from Postgres statistics + the total database size.
@@ -156,8 +153,8 @@ async def get_table_health() -> dict:
"dead_pct": float(r["dead_pct"] or 0),
"total_bytes": int(r["total_bytes"] or 0),
"mod_since_analyze": int(r["mod_since_analyze"] or 0),
"last_vacuum": _iso(r["last_vacuum"]),
"last_analyze": _iso(r["last_analyze"]),
"last_vacuum": iso(r["last_vacuum"]),
"last_analyze": iso(r["last_analyze"]),
}
for r in rows
]
+15 -43
View File
@@ -1,9 +1,8 @@
"""Daily APScheduler cron for basic DB maintenance (targeted VACUUM ANALYZE).
Mirrors trash_scheduler.py: a single global BackgroundScheduler job bridges
into the asyncio loop to run the async maintenance. Scheduled for 04:00 UTC by
default — after the 03:30 trash purge — so it collects the dead tuples that
night's delete sweeps leave behind.
One ScheduledJob (services/scheduler.py). Scheduled for 04:00 UTC by default
— after the 03:30 trash purge — so it collects the dead tuples that night's
delete sweeps leave behind.
Two things are operator-tunable from the admin Settings card:
- db_maintenance_enabled ("true"/"false") — checked at fire time, so toggling
@@ -16,9 +15,9 @@ from __future__ import annotations
import asyncio
import logging
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from scribe.services.scheduler import ScheduledJob
from scribe.services.settings import get_admin_setting
logger = logging.getLogger(__name__)
@@ -26,9 +25,6 @@ logger = logging.getLogger(__name__)
_JOB_ID = "db_maintenance_vacuum"
_DEFAULT_HOUR = 4
_scheduler: BackgroundScheduler | None = None
_loop: asyncio.AbstractEventLoop | None = None
async def get_maintenance_hour() -> int:
"""The configured run-hour (UTC, 023), clamped; default 04:00."""
@@ -45,23 +41,20 @@ async def is_maintenance_enabled() -> bool:
return (await get_admin_setting("db_maintenance_enabled", "true")) != "false"
def _run_maintenance_threadsafe() -> None:
"""APScheduler invokes this from a worker thread; bridge into the loop."""
if _loop is None:
logger.warning("db maintenance scheduler: no loop registered")
return
async def _runner():
try:
async def _run_maintenance() -> None:
if not await is_maintenance_enabled():
logger.debug("db maintenance: disabled, skipping scheduled run")
return
from scribe.services.db_maintenance import run_maintenance
await run_maintenance()
except Exception:
logger.exception("db maintenance run failed")
asyncio.run_coroutine_threadsafe(_runner(), _loop)
_JOB = ScheduledJob(_JOB_ID, _run_maintenance, label="DB maintenance")
def _trigger(hour: int) -> CronTrigger:
hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR
return CronTrigger(hour=hour, minute=0, timezone="UTC")
def start_db_maintenance_scheduler(
@@ -72,36 +65,15 @@ def start_db_maintenance_scheduler(
in rather than read here so we never block the event loop at startup. The
job's enabled-gate is re-checked at every fire, so only the hour is needed
up front."""
global _scheduler, _loop
if _scheduler is not None:
return
_loop = loop
hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR
_scheduler = BackgroundScheduler()
_scheduler.add_job(
_run_maintenance_threadsafe,
trigger=CronTrigger(hour=hour, minute=0, timezone="UTC"),
id=_JOB_ID,
replace_existing=True,
)
_scheduler.start()
logger.info("DB maintenance scheduler started (daily %02d:00 UTC)", hour)
_JOB.start(loop, _trigger(hour), describe=f"daily {hour:02d}:00 UTC")
def reschedule_db_maintenance(hour: int) -> None:
"""Move the live job to a new UTC hour (called when the admin changes it)."""
if _scheduler is None:
return
hour = hour if 0 <= hour <= 23 else _DEFAULT_HOUR
_scheduler.reschedule_job(
_JOB_ID, trigger=CronTrigger(hour=hour, minute=0, timezone="UTC")
)
logger.info("DB maintenance scheduler rescheduled to %02d:00 UTC", hour)
_JOB.reschedule(_trigger(hour), describe=f"{hour:02d}:00 UTC")
def stop_db_maintenance_scheduler() -> None:
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("DB maintenance scheduler stopped")
_JOB.stop()
+3 -2
View File
@@ -32,6 +32,7 @@ from scribe.models import async_session
from scribe.models.embedding import NoteEmbedding
from scribe.models.note import Note
from scribe.models.rulebook import Rule
from scribe.models.base import iso
from scribe.services import embeddings as embeddings_svc
# Imported rather than redeclared: no service imports this module (the create
# gate is called from the routes/tools layer), so there is no cycle to dodge,
@@ -592,8 +593,8 @@ async def find_duplicate_records(
titles[int(i)] = t
records[int(i)] = d or {}
meta[int(i)] = {
"created_at": created.isoformat() if created else None,
"updated_at": updated.isoformat() if updated else None,
"created_at": iso(created),
"updated_at": iso(updated),
"task_kind": task_kind,
}
except Exception:
+124 -40
View File
@@ -8,10 +8,12 @@ webhooks (step 6), and coverage can be measured (step 7).
Design constraints, in force everywhere below:
- OPTIONAL per instance (rule #115). `get_forge()` returns None when nothing
is configured, and every consumer must treat None as "keep today's
behavior". An install that never configures a forge is not degraded — it
is the baseline.
- OPTIONAL per user (rule #115, sharpened by #2778). Connections are
per-user keyring rows resolved by repo host on the PROJECT OWNER's
keyring; `get_forges()` returns an empty selector when the owner has
nothing configured, and every consumer must treat that as "keep today's
behavior". A user who never configures a forge is not degraded — that is
the baseline.
- READ-ONLY by construction. The adapter exposes reads; there is no write
method to misuse. The token an operator mints for it only ever needs read
scope, and the docs say so.
@@ -40,18 +42,14 @@ from dataclasses import dataclass
from urllib.parse import quote, urlsplit
import httpx
from sqlalchemy import select
from scribe.config import Config
from scribe.services.repo_bindings import normalize_repo_key
from scribe.services.settings import get_admin_setting
logger = logging.getLogger(__name__)
FORGE_KIND_KEY = "forge_kind"
FORGE_BASE_URL_KEY = "forge_base_url"
FORGE_TOKEN_KEY = "forge_token"
# Kinds an instance can configure. Matches _FORGE_CLASSES below.
# Kinds a connection can use. Matches _FORGE_CLASSES below.
FORGE_KINDS = ("gitea", "github")
# Total budget per forge call. Consumers either have a cache to fall back to
@@ -86,7 +84,8 @@ class ForgeFile:
path: str
def _host_of(url: str) -> str:
def host_of(url: str) -> str:
"""The lowercase hostname of a URL — the keyring's lookup key (#2778)."""
return (urlsplit(url).hostname or "").lower()
@@ -111,7 +110,7 @@ class ForgeAdapter:
@property
def host(self) -> str:
return _host_of(self.base_url)
return host_of(self.base_url)
def resolve_repo(self, repo_or_url: str) -> str | None:
"""The forge-API repo path for a recorded repo — or None if this forge
@@ -359,39 +358,124 @@ _FORGE_CLASSES: dict[str, type[ForgeAdapter]] = {
}
async def forge_config() -> dict:
"""The instance's forge configuration, DB-first with env fallback.
def build_adapter(
kind: str, base_url: str, token: str, *, transport=None
) -> ForgeAdapter | None:
"""One validated adapter from raw connection values, or None.
The env channel exists so a deployment can keep the token out of the
database entirely (Docker secret via FORGE_TOKEN_FILE) — the DB value wins
when both are present because the admin UI writes there, and a UI edit
that silently loses to an env var would look exactly like a broken form.
None means "this connection cannot serve reads" — the same contract the
old instance-wide lookup had, applied per keyring row. Misconfigurations
are logged, never raised: a bad row must not break the reads the good
rows can still serve.
"""
return {
"kind": (await get_admin_setting(FORGE_KIND_KEY, "") or Config.FORGE_KIND)
.strip()
.lower(),
"base_url": (
await get_admin_setting(FORGE_BASE_URL_KEY, "") or Config.FORGE_BASE_URL
).rstrip("/"),
"token": await get_admin_setting(FORGE_TOKEN_KEY, "") or Config.FORGE_TOKEN,
}
async def get_forge(*, transport=None) -> ForgeAdapter | None:
"""The configured forge adapter, or None — and None means "behave exactly
as if this module did not exist", which every consumer must honor."""
cfg = await forge_config()
cls = _FORGE_CLASSES.get(cfg["kind"])
kind = (kind or "").strip().lower()
base_url = (base_url or "").rstrip("/")
cls = _FORGE_CLASSES.get(kind)
if cls is None:
if cfg["kind"]:
if kind:
# A kind we don't implement is a misconfiguration, not "off" —
# say so once per lookup rather than silently reading as absent.
logger.warning("unknown forge kind %r configured — forge disabled", cfg["kind"])
logger.warning("unknown forge kind %r configured — connection disabled", kind)
return None
if not cfg["base_url"] or not cfg["token"]:
if not base_url or not token:
return None
if not cfg["base_url"].startswith(("http://", "https://")):
logger.warning("forge base URL %r has no http(s) scheme — forge disabled", cfg["base_url"])
if not base_url.startswith(("http://", "https://")):
logger.warning("forge base URL %r has no http(s) scheme — connection disabled", base_url)
return None
return cls(cfg["base_url"], cfg["token"], transport=transport)
return cls(base_url, token, transport=transport)
@dataclass(frozen=True)
class ForgeSelector:
"""The forge reads available to one project owner (#2778).
Consumers ask it to serve a REPO, not to hand over "the forge": resolve()
walks the owner's adapters and returns the (adapter, api_repo) pair for
the first one whose host serves the repo — or None, which every consumer
treats exactly as the old "no forge configured" state. An empty selector
IS rule #115's baseline.
"""
adapters: tuple[ForgeAdapter, ...] = ()
@property
def configured(self) -> bool:
return bool(self.adapters)
def resolve(self, repo_or_url: str) -> tuple[ForgeAdapter, str] | None:
for adapter in self.adapters:
repo = adapter.resolve_repo(repo_or_url)
if repo is not None:
return adapter, repo
return None
async def get_forges(
owner_id: int, project_id: int | None = None, *, transport=None
) -> ForgeSelector:
"""The forge selector for reads on behalf of ``owner_id``'s records.
The keyring model (#2778): every server-side forge read for a record runs
on the PROJECT OWNER's connections, resolved by repo host — a forge token
is a user's credential, and one user's reads must never ride another
user's token. Pass the record's ``project_id`` so the per-project pin
applies: a pinned project uses ONLY its pinned connection (explicit and
auditable); a pin that no longer belongs to the owner (ownership moved) is
ignored with a warning rather than honored across users.
The env config (FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) survives as an
implicit keyring entry for ADMIN owners only — it is the operator's
token, so it must not serve other users' reads — and a stored row for the
same host beats it, because the UI writes rows.
"""
from scribe.models import async_session
from scribe.models.forge_connection import ForgeConnection
from scribe.models.project import Project
from scribe.models.user import User
async with async_session() as session:
pinned_id = None
if project_id:
pinned_id = (
await session.execute(
select(Project.forge_connection_id).where(Project.id == project_id)
)
).scalar_one_or_none()
rows = list(
(
await session.execute(
select(ForgeConnection)
.where(ForgeConnection.user_id == owner_id)
.order_by(ForgeConnection.id)
)
).scalars().all()
)
role = ""
if Config.FORGE_KIND and Config.FORGE_BASE_URL and Config.FORGE_TOKEN:
role = (
await session.execute(select(User.role).where(User.id == owner_id))
).scalar_one_or_none() or ""
if pinned_id:
pin = next((r for r in rows if r.id == pinned_id), None)
if pin is not None:
adapter = build_adapter(pin.kind, pin.base_url, pin.token, transport=transport)
return ForgeSelector((adapter,) if adapter is not None else ())
logger.warning(
"project %s pins forge connection %s the owner (%s) does not hold — pin ignored",
project_id, pinned_id, owner_id,
)
adapters: list[ForgeAdapter] = []
for row in rows:
adapter = build_adapter(row.kind, row.base_url, row.token, transport=transport)
if adapter is not None:
adapters.append(adapter)
if role == "admin":
env = build_adapter(
Config.FORGE_KIND, Config.FORGE_BASE_URL, Config.FORGE_TOKEN,
transport=transport,
)
if env is not None and all(a.host != env.host for a in adapters):
adapters.append(env)
return ForgeSelector(tuple(adapters))
+194
View File
@@ -0,0 +1,194 @@
"""User-level forge connection CRUD — the keyring rows get_forges reads (#2778).
A connection is a user's read-only credential for one forge host; one row per
(user, host) keeps host-keyed resolution deterministic with no default-pointer
machinery. Everything here is own-rows-only: a connection is a credential, and
no caller — admin included — reads or edits another user's. The token never
leaves the server (model.to_dict omits it; routes mask "set/unset").
Validation matches what build_adapter will accept, checked here so a bad
value is a 400 at the form instead of a silently dead keyring row.
"""
from __future__ import annotations
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.forge_connection import ForgeConnection
from scribe.models.project import Project
from scribe.services.forge import FORGE_KINDS, host_of
def validate_connection(kind: str, base_url: str) -> str | None:
"""The error a connection's non-secret values would earn, or None."""
if kind not in FORGE_KINDS:
return f"Unknown forge kind {kind!r} (one of: {', '.join(FORGE_KINDS)})"
if not base_url.startswith(("http://", "https://")):
return "Forge base URL must use http or https"
if not host_of(base_url):
return "Forge base URL carries no hostname"
return None
async def list_connections(user_id: int) -> list[ForgeConnection]:
async with async_session() as session:
rows = await session.execute(
select(ForgeConnection)
.where(ForgeConnection.user_id == user_id)
.order_by(ForgeConnection.host)
)
return list(rows.scalars().all())
async def get_connection(user_id: int, connection_id: int) -> ForgeConnection | None:
async with async_session() as session:
return (
await session.execute(
select(ForgeConnection).where(
ForgeConnection.id == connection_id,
ForgeConnection.user_id == user_id,
)
)
).scalar_one_or_none()
async def create_connection(
user_id: int, *, kind: str, base_url: str, token: str
) -> ForgeConnection:
"""Create a keyring row. Raises ValueError on bad values or a host the
user already holds — one row per (user, host) IS the resolution model,
so a second token for the same host is an update, not a create."""
kind = (kind or "").strip().lower()
base_url = (base_url or "").strip().rstrip("/")
token = token or ""
error = validate_connection(kind, base_url)
if error is None and not token:
error = "A token is required (read scope is enough)"
if error:
raise ValueError(error)
host = host_of(base_url)
async with async_session() as session:
existing = (
await session.execute(
select(ForgeConnection).where(
ForgeConnection.user_id == user_id,
ForgeConnection.host == host,
)
)
).scalar_one_or_none()
if existing is not None:
raise ValueError(
f"You already have a connection for {host} — edit that one; "
"resolution is by host, so a second row could never be reached"
)
row = ForgeConnection(
user_id=user_id, kind=kind, base_url=base_url, host=host, token=token
)
session.add(row)
await session.commit()
await session.refresh(row)
return row
async def update_connection(
user_id: int,
connection_id: int,
*,
kind: str = "",
base_url: str = "",
token: str = "",
) -> ForgeConnection | None:
"""Update own row; empty string = leave unchanged (the settings-form
sentinel convention). None when the row isn't the caller's."""
async with async_session() as session:
row = (
await session.execute(
select(ForgeConnection).where(
ForgeConnection.id == connection_id,
ForgeConnection.user_id == user_id,
)
)
).scalar_one_or_none()
if row is None:
return None
new_kind = (kind or "").strip().lower() or row.kind
new_base = (base_url or "").strip().rstrip("/") or row.base_url
error = validate_connection(new_kind, new_base)
if error:
raise ValueError(error)
new_host = host_of(new_base)
if new_host != row.host:
clash = (
await session.execute(
select(ForgeConnection.id).where(
ForgeConnection.user_id == user_id,
ForgeConnection.host == new_host,
ForgeConnection.id != row.id,
)
)
).scalar_one_or_none()
if clash is not None:
raise ValueError(
f"You already have a connection for {new_host} — edit that one"
)
row.kind = new_kind
row.base_url = new_base
row.host = new_host
if token:
row.token = token
await session.commit()
await session.refresh(row)
return row
async def delete_connection(user_id: int, connection_id: int) -> bool:
"""Delete own row. Project pins pointing at it go NULL (FK SET NULL) —
those projects fall back to keyring resolution, which is the documented
unpinned behavior, not a surprise."""
async with async_session() as session:
row = (
await session.execute(
select(ForgeConnection).where(
ForgeConnection.id == connection_id,
ForgeConnection.user_id == user_id,
)
)
).scalar_one_or_none()
if row is None:
return False
await session.delete(row)
await session.commit()
return True
async def set_project_pin(
owner_id: int, project_id: int, connection_id: int | None
) -> bool:
"""Point a project at one of its OWNER's connections, or clear the pin.
The caller settles WHO may ask (routes check owner-or-admin); this
settles WHOSE connection is eligible: only the project owner's — pinning
a collaborator's token to someone else's project is the confused-deputy
channel this feature exists to close. False = project or connection not
eligible.
"""
async with async_session() as session:
project = (
await session.execute(select(Project).where(Project.id == project_id))
).scalar_one_or_none()
if project is None or (project.user_id or 0) != owner_id:
return False
if connection_id:
held = (
await session.execute(
select(ForgeConnection.id).where(
ForgeConnection.id == connection_id,
ForgeConnection.user_id == owner_id,
)
)
).scalar_one_or_none()
if held is None:
return False
project.forge_connection_id = connection_id or None
await session.commit()
return True
+14 -3
View File
@@ -16,12 +16,14 @@ endorsed, so a one-off direct share has to be searched for rather than arriving
in your ambient lists.
"""
import json
import re
import logging
from sqlalchemy import and_, func, or_, select
from scribe.models import async_session
from scribe.models.note import Note
from scribe.models.base import iso
from scribe.services.access import browsable_notes_clause, readable_notes_clause
logger = logging.getLogger(__name__)
@@ -75,6 +77,10 @@ def location_matches(data: dict | None, parts: dict[str, str]) -> bool:
if all(
_path_matches((loc.get(key) or "").strip(), want)
if key == "path"
# Repo names are recorded free-form ("Scribe" / "FabledScribe" /
# "fabledscribe") — case is never the distinguishing thing (#2874).
else (loc.get(key) or "").strip().lower() == want.lower()
if key == "repo"
else (loc.get(key) or "").strip() == want
for key, want in parts.items()
):
@@ -98,6 +104,11 @@ def location_jsonpath(parts: dict[str, str]) -> str:
if key == "path":
prefix = json.dumps(want.rstrip("/") + "/")
filters.append(f"(@.path == {literal} || @.path starts with {prefix})")
elif key == "repo":
# Case-insensitive, anchored, regex-escaped (#2874) — mirrors the
# Python dialect's .lower() compare.
pattern = json.dumps("^" + re.escape(want) + "$")
filters.append(f'(@.repo like_regex {pattern} flag "i")')
else:
filters.append(f"@.{key} == {literal}")
return f"$.locations[*] ? ({' && '.join(filters)})"
@@ -211,8 +222,8 @@ def _note_to_item(note: Note) -> dict:
# These lists now include records shared with the caller, so the client
# needs the owner to tell "mine" from "someone else's" in a mixed list.
"user_id": note.user_id,
"created_at": note.created_at.isoformat(),
"updated_at": note.updated_at.isoformat(),
"created_at": iso(note.created_at),
"updated_at": iso(note.updated_at),
}
# Drift verdict (#2086), when one has been recorded. Included here rather
# than decorated on by the snippet layer because `current` is derivable from
@@ -249,7 +260,7 @@ def _note_to_item(note: Note) -> dict:
item["task_kind"] = note.task_kind
item["status"] = note.status
item["priority"] = note.priority
item["due_date"] = note.due_date.isoformat() if note.due_date else None
item["due_date"] = iso(note.due_date)
return item
+3 -7
View File
@@ -194,18 +194,14 @@ async def delete_old_logs(retention_days: int) -> int:
return result.rowcount
async def _retention_loop() -> None:
while True:
await asyncio.sleep(3600) # hourly
try:
async def _retention_tick() -> None:
deleted = await delete_old_logs(Config.LOG_RETENTION_DAYS)
if deleted:
logger.info("Log retention: deleted %d old log entries", deleted)
except Exception:
logger.exception("Error in log retention cleanup")
def start_log_retention_loop() -> None:
global _retention_task
if _retention_task is None or _retention_task.done():
_retention_task = asyncio.create_task(_retention_loop())
from scribe.services.background import start_periodic
_retention_task = start_periodic(3600, _retention_tick, label="log_retention") # hourly
+3 -9
View File
@@ -235,12 +235,6 @@ async def get_project_milestone_summaries(
async def get_project_milestone_summary(user_id: int, project_id: int) -> list[dict]:
"""Return ordered list of milestones with their progress stats."""
milestones = await list_milestones(user_id, project_id)
result = []
for m in milestones:
progress = await get_milestone_progress(m.id)
entry = m.to_dict()
entry.update(progress)
result.append(entry)
return result
"""Ordered milestones with progress — the one-project view of
get_project_milestone_summaries (two queries, not N+1)."""
return (await get_project_milestone_summaries(user_id, [project_id])).get(project_id, [])
+3 -2
View File
@@ -36,6 +36,7 @@ from sqlalchemy import case, func, select
from scribe.models import async_session
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
from scribe.models.base import iso
logger = logging.getLogger(__name__)
@@ -232,13 +233,13 @@ async def usage_for_notes(note_ids: list[int]) -> dict[int, dict]:
slot["ambient_count"] = int(n)
elif event == SURFACED:
slot["surfaced_count"] = int(n)
slot["last_surfaced_at"] = last_at.isoformat() if last_at else None
slot["last_surfaced_at"] = iso(last_at)
elif event == PULLED:
# Pulls are pulls regardless of what surfaced the record — the
# question a pull answers ("did anyone ever open this?") doesn't
# depend on how it was found.
slot["pull_count"] = slot["pull_count"] + int(n)
latest = last_at.isoformat() if last_at else None
latest = iso(last_at)
if latest and (slot["last_pulled_at"] or "") < latest:
slot["last_pulled_at"] = latest
return out
+17 -37
View File
@@ -3,17 +3,20 @@
import asyncio
import json
import logging
from datetime import date, datetime, time, timezone
from datetime import date, datetime, time, timedelta, timezone
from sqlalchemy import func, select, text
from sqlalchemy import delete as sa_delete, func, select, text
from sqlalchemy import update as sa_update
from scribe.models import async_session
from scribe.models.app_log import AppLog
from scribe.models.note import Note
from scribe.models.setting import Setting
from scribe.models.notification import Notification
from scribe.models.user import User
from scribe.models.base import iso
from scribe.services.email import _email_html, is_smtp_configured, send_email
from scribe.services.logging import log_audit
from scribe.services.settings import get_setting
logger = logging.getLogger(__name__)
@@ -29,13 +32,7 @@ SECURITY_EVENT_LABELS = {
async def _get_user_notification_pref(user_id: int, key: str) -> bool:
"""Check if a user has a notification preference enabled (default True)."""
async with async_session() as session:
result = await session.execute(
select(Setting).where(Setting.user_id == user_id, Setting.key == key)
)
setting = result.scalar_one_or_none()
# Default to enabled
return setting.value != "false" if setting else True
return await get_setting(user_id, key, "true") != "false"
async def _get_user_email(user_id: int) -> str | None:
@@ -222,7 +219,7 @@ async def check_due_tasks() -> None:
for task in user_tasks:
overdue = task.due_date < today if task.due_date else False
date_color = "#ef4444" if overdue else "#374151"
date_label = f'<span style="color: {date_color};">{task.due_date.isoformat()}</span>' if task.due_date else ""
date_label = f'<span style="color: {date_color};">{iso(task.due_date)}</span>' if task.due_date else ""
overdue_badge = ' <span style="color:#ef4444;font-weight:600;font-size:11px;">(overdue)</span>' if overdue else ""
task_rows += (
f'<tr>'
@@ -261,13 +258,10 @@ _NOTIFICATION_RETENTION_DAYS = 30
async def purge_old_read_notifications(retention_days: int = _NOTIFICATION_RETENTION_DAYS) -> int:
"""Delete already-read in-app notifications older than retention_days."""
from datetime import timedelta
from sqlalchemy import delete
from scribe.models.notification import Notification
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
async with async_session() as session:
result = await session.execute(
delete(Notification).where(
sa_delete(Notification).where(
Notification.read_at.isnot(None),
Notification.read_at < cutoff,
)
@@ -276,25 +270,21 @@ async def purge_old_read_notifications(retention_days: int = _NOTIFICATION_RETEN
return result.rowcount or 0
async def _notification_loop() -> None:
while True:
await asyncio.sleep(3600) # hourly
async def _notification_tick() -> None:
try:
await check_due_tasks()
except Exception:
logger.exception("Error in notification loop")
try:
removed = await purge_old_read_notifications()
if removed:
logger.info("Notification retention: deleted %d read notification(s)", removed)
except Exception:
logger.exception("Error in notification retention cleanup")
def start_notification_loop() -> None:
global _notification_task
if _notification_task is None or _notification_task.done():
_notification_task = asyncio.create_task(_notification_loop())
from scribe.services.background import start_periodic
_notification_task = start_periodic(3600, _notification_tick, label="notifications") # hourly
# ---------------------------------------------------------------------------
@@ -303,7 +293,6 @@ def start_notification_loop() -> None:
async def create_in_app_notification(user_id: int, notif_type: str, payload: dict):
"""Create an in-app Notification record."""
from scribe.models.notification import Notification
async with async_session() as session:
n = Notification(user_id=user_id, type=notif_type, payload=payload)
session.add(n)
@@ -316,11 +305,10 @@ async def _fire_share_email(user_id: int, subject: str, body_text: str) -> None:
try:
if not await is_smtp_configured():
return
async with async_session() as session:
user = await session.get(User, user_id)
if user and user.email:
email = await _get_user_email(user_id)
if email:
html = _email_html(subject, f"<p>{body_text.replace(chr(10), '<br>')}</p>")
await send_email(user.email, subject, html)
await send_email(email, subject, html)
except Exception:
logger.exception("Share email notification failed for user %d", user_id)
@@ -427,7 +415,6 @@ async def notify_group_added(
async def list_in_app_notifications(user_id: int, unread_only: bool = True) -> list[dict]:
from scribe.models.notification import Notification
async with async_session() as session:
q = select(Notification).where(Notification.user_id == user_id)
if unread_only:
@@ -438,7 +425,6 @@ async def list_in_app_notifications(user_id: int, unread_only: bool = True) -> l
async def unread_notification_count(user_id: int) -> int:
from scribe.models.notification import Notification
async with async_session() as session:
result = await session.execute(
select(func.count()).where(
@@ -450,8 +436,6 @@ async def unread_notification_count(user_id: int) -> int:
async def mark_notification_read(user_id: int, notification_id: int) -> bool:
from scribe.models.notification import Notification
from datetime import timezone as tz
async with async_session() as session:
n = (await session.execute(
select(Notification).where(
@@ -461,21 +445,17 @@ async def mark_notification_read(user_id: int, notification_id: int) -> bool:
)).scalar_one_or_none()
if not n:
return False
from datetime import datetime
n.read_at = datetime.now(tz.utc)
n.read_at = datetime.now(timezone.utc)
await session.commit()
return True
async def mark_all_notifications_read(user_id: int) -> int:
from scribe.models.notification import Notification
from datetime import datetime, timezone as tz
from sqlalchemy import update as sa_update
async with async_session() as session:
result = await session.execute(
sa_update(Notification)
.where(Notification.user_id == user_id, Notification.read_at.is_(None))
.values(read_at=datetime.now(tz.utc))
.values(read_at=datetime.now(timezone.utc))
.returning(Notification.id)
)
await session.commit()
+1 -6
View File
@@ -60,12 +60,7 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict:
return {
"milestone": milestone.to_dict(),
"applicable_rules": applicable["rules"],
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
"applicable_rules_truncated": applicable["truncated"],
"project_rules": applicable.get("project_rules", []),
"suppressed_rules": applicable.get("suppressed_rules", []),
"suppressed_topics": applicable.get("suppressed_topics", []),
**rulebooks_svc.rules_payload(applicable),
"project_goal": getattr(project, "goal", "") or "",
"open_task_count": open_count,
}
+195 -35
View File
@@ -27,6 +27,7 @@ from scribe.services import knowledge as knowledge_svc
from scribe.services import notes as notes_svc
from scribe.services import projects as projects_svc
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
@@ -702,6 +703,9 @@ async def build_write_path_hint(
code: str = "",
project_id: int = 0,
exclude_ids: list[int] | None = None,
exclude_sync_ids: list[int] | None = None,
stamp_shapes: list[tuple[str, str]] | None = None,
repo_key: str = "",
) -> dict:
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
@@ -709,11 +713,24 @@ async def build_write_path_hint(
convention snippet locations are recorded in. `code` is what's about to be
written, used only as the semantic query.
Carries auto-inject's anti-bloat gates (margin, session dedup via
`exclude_ids`, titles-never-bodies) plus the shared top-k cap across BOTH
arms — so a file with a lot of recorded history can't turn one edit into a
wall of text. Two gates are its OWN, because code is not prose: a stricter
similarity threshold, and a minimum-substance floor on `code` below which the
A hit recorded AT this exact path is not a reuse suggestion — it IS the
record of the file being changed, so it renders as the SYNC class (#2708):
"this snippet records the file you're editing; if the edit changes the
recorded shape, updating the record is part of the edit." That is the
operator's chosen alternative to server-side drift flagging (decision
#2707): the record gets corrected in the session that has the context,
at the moment of change. Nearby and semantic hits stay the REUSE menu.
The two classes dedup on SEPARATE channels — `exclude_ids` (reuse) and
`exclude_sync_ids` (sync) — because they answer different questions: a
title shown as "consider reusing this" twenty turns ago must not silence
"you are editing the recorded file right now" (#2708).
Carries auto-inject's anti-bloat gates (margin, session dedup,
titles-never-bodies) plus the shared top-k cap across ALL arms — so a file
with a lot of recorded history can't turn one edit into a wall of text. Two
gates are its OWN, because code is not prose: a stricter similarity
threshold, and a minimum-substance floor on `code` below which the
semantic arm doesn't run at all (#2223 — see WRITEPATH_DEFAULT_THRESHOLD and
WRITEPATH_MIN_CODE_CHARS). Returns empty context when disabled, when there's
no path, or when nothing is recorded — which is the common case, and the point.
@@ -724,28 +741,45 @@ async def build_write_path_hint(
free-text label the operator typed ("Scribe"), not a remote URL, and matching
one against the other would silently return nothing.
Returns {"context": str, "note_ids": list[int], "config": dict}. The semantic
arm is logged to retrieval_logs as source='write_path' — its own source, so
its precision is tunable separately from auto-inject's.
Returns {"context": str, "note_ids": list[int], "sync_note_ids": list[int],
"config": dict} — `sync_note_ids` is the subset of `note_ids` shown as the
sync class, so the hook can feed each dedup channel its own ids. The
semantic arm is logged to retrieval_logs as source='write_path' — its own
source, so its precision is tunable separately from auto-inject's.
Location hits still carry no score and so stay out of retrieval_logs, whose
score distribution they would corrupt. What closed the gap (#2085) is that
un-scored surfacing now has its own home: BOTH arms emit note_usage_events,
tagged 'write_path_place' vs 'write_path_semantic', so the place arm is
finally measurable — and the two arms' pull-through rates are comparable,
which is the number that says whether place really does beat meaning here.
un-scored surfacing now has its own home: every arm emits note_usage_events,
tagged 'write_path_sync' vs 'write_path_place' vs 'write_path_semantic', so
each claim's pull-through rate is measurable on its own.
``stamp_shapes`` turns the same request into the ledger's write-path feed
(#2791): the (kind, name) definitions the hook saw in — or enclosing —
the payload. When the session has PULLED a snippet recently and this
payload references or resembles it, those shapes land as `instance` rows
(classified_by=hook, see shape_ledger.stamp_write_path_instances) and
the result's ``stamped`` lists them. The route passes it only for a
caller allowed to write — a read-scoped key gets the hint, never the
stamp. ``repo_key`` (the hook's remote, normalised) homes a provisional
row for a shape the ledger has not synced yet.
"""
cfg = await get_writepath_config(user_id)
empty = {"context": "", "note_ids": [], "config": cfg}
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg,
"stamped": [], "divergence": []}
path = (path or "").strip()
if not cfg["enabled"] or not path:
return empty
top_k = cfg["top_k"]
excluded = set(exclude_ids or [])
sync_excluded = set(exclude_sync_ids or [])
scope_project = project_id or None
# --- arm 1: by place ---
# --- the sync class, and arm 1 by place ---
# `path` matches exact-or-under (see knowledge.py), and nothing sits under
# a FILE path — so the file query returns precisely the snippets recorded
# AT this path: the sync class. The directory query is the reuse-shaped
# "nearby" arm, unchanged.
here: list[dict] = []
nearby: list[dict] = []
try:
@@ -760,19 +794,38 @@ async def build_write_path_hint(
except Exception:
logger.warning("Write-path location lookup failed", exc_info=True)
# Sync hits dedup ONLY against their own channel — reuse-`excluded` ids
# stay eligible here, which is the whole point of the split. Either way
# they join `seen`, so the reuse arms (where the directory query would
# surface them again) never re-list a record the sync block owns.
seen: set[int] = set(excluded)
synced: list[dict] = []
for item in here:
nid = int(item["id"])
seen.add(nid)
if nid not in sync_excluded and len(synced) < top_k:
synced.append(item)
placed: list[tuple[str, dict]] = []
for marker, items in (("here", here), ("nearby", nearby)):
for item in items:
for item in nearby:
nid = int(item["id"])
if nid in seen:
continue
seen.add(nid)
placed.append((marker, item))
placed.append(("nearby", item))
# The stamping feed's "actually pulled it" half (#2791). Read once, before
# the semantic arm, because the arm's query doubles as the resemblance
# test: a pulled snippet this session already saw (so it sits in `seen`)
# must still be SCORED for this payload — it just isn't re-listed.
pulled: dict = {}
if stamp_shapes:
pulled = await shape_ledger_svc.recent_pulls(user_id)
resembles: dict[int, float] = {}
# --- arm 2: by meaning ---
scored: list[tuple[str, dict]] = []
remaining = top_k - len(placed)
remaining = top_k - len(synced) - len(placed)
query = (code or "").strip()
# Drop payloads too small to carry meaning before spending an embedding on
# them — a one-line Edit is not a helper being rewritten, and its embedding
@@ -791,12 +844,15 @@ async def build_write_path_hint(
query = concept_query(query) or query
if remaining > 0 and query:
t0 = time.perf_counter()
# Pulled-and-seen ids stay in the query (as evidence) but never in
# the menu — the dedup contract holds, the resemblance still lands.
pulled_seen = seen & set(pulled)
hits = await semantic_search_notes(
user_id, query,
limit=remaining,
limit=remaining + len(pulled_seen),
threshold=cfg["threshold"],
project_id=scope_project,
exclude_ids=seen,
exclude_ids=seen - pulled_seen,
# Snippets AND recorded experience (#2246). This arm was
# snippets-only, which is auto-inject's mistake inverted: an issue
# saying "we tried this and it deadlocked", or a dev-log recording
@@ -815,6 +871,11 @@ async def build_write_path_hint(
# the browse scope and never surfaces a one-to-one direct share.
scope="browse",
)
resembles = {
int(note.id): float(score) for score, note in hits
if int(note.id) in pulled
}
hits = [(s, n) for s, n in hits if int(n.id) not in seen][:remaining]
record_retrieval(
user_id=user_id, source="write_path", query=query,
threshold=cfg["threshold"], limit=remaining,
@@ -848,31 +909,79 @@ async def build_write_path_hint(
},
))
menu = (placed + scored)[:top_k]
if not menu:
menu = (placed + scored)[:max(0, top_k - len(synced))]
# The stamp runs whether or not anything is rendered — after dedup, the
# common case is a silent hint and a pulled canon being instantiated.
stamped: list[dict] = []
if stamp_shapes and pulled:
try:
stamped = await shape_ledger_svc.stamp_write_path_instances(
user_id, project_id, path=path, shapes=stamp_shapes,
code=code or "", pulled=pulled, resembles=resembles,
repo_key=repo_key,
)
except Exception:
logger.warning("Write-path ledger stamping failed", exc_info=True)
# The in-band button-B check (#2793): the hook named the shapes being
# written; if this directory+kind is canon-dense and a named shape isn't
# (about to be) an instance of that canon, say so NOW — at the write,
# not at the next audit.
divergence: list[dict] = []
if stamp_shapes and project_id:
try:
divergence = await shape_ledger_svc.write_time_divergence(
project_id, path, stamp_shapes, stamped
)
except Exception:
logger.warning("write-time divergence check failed", exc_info=True)
if not synced and not menu and not stamped and not divergence:
return empty
owners = await owner_names_for({
int(it["user_id"]) for _m, it in menu
int(it["user_id"]) for it in synced + [it for _m, it in menu]
if it.get("user_id") is not None and int(it["user_id"]) != user_id
})
def _owner_of(item: dict) -> str | None:
owner_id = item.get("user_id")
if owner_id is None or int(owner_id) == user_id:
return None
return owners.get(int(owner_id)) or "another user"
target_lang = _language_for_path(path)
rendered: list[tuple[dict, str, str | None, str]] = []
for marker, item in menu:
owner_id = item.get("user_id")
owner = None
if owner_id is not None and int(owner_id) != user_id:
owner = owners.get(int(owner_id)) or "another user"
rendered.append((item, marker, owner, _foreign_language(item, target_lang)))
rendered.append((item, marker, _owner_of(item), _foreign_language(item, target_lang)))
lines = [
lines: list[str] = []
sync_note_ids: list[int] = []
if synced:
# The sync framing (#2708). Deliberately imperative about the record —
# the reuse claim ("start from this shape") is still implied by the
# title being right there, but the load-bearing sentence is the one no
# other surface says: keeping the record true is part of THIS edit.
lines.append(
f"> Recorded in Scribe AT `{path}` — the snippet(s) below record "
"the file this edit is changing. Reuse/extend the recorded shape "
"rather than writing a parallel one; and if this edit changes what "
"a record captures, updating it is part of the edit: "
"`update_snippet(id, code=…)` with the new shape, or "
"`verify_snippet(id, status=\"ok\", commit_sha=…)` after confirming "
"it still holds. Open with `get_snippet(id)` (shown once per session):"
)
for item in synced:
sync_note_ids.append(int(item["id"]))
lines.append(_prior_art_line(item, "records this file", _owner_of(item)))
if menu:
lines.append(
f"> Prior art already recorded in Scribe for `{path}` — open one with "
"`get_snippet(id)` for a snippet, `get_task(id)` for an issue, "
"`get_note(id)` otherwise. Reuse a snippet rather than writing a fresh "
"one-off; read an issue before repeating what it records "
"(titles only; shown once per session):",
]
"(titles only; shown once per session):"
)
# Say what a language tag MEANS, and only when one is actually on the menu.
# Without this the reader has to infer why "· python" is attached to a hit on
# a .ts file, and the two ways of guessing wrong are both bad: dismiss it as
@@ -885,24 +994,75 @@ async def build_write_path_hint(
"shape of a solution to adapt, not code to copy."
)
note_ids: list[int] = []
note_ids: list[int] = list(sync_note_ids)
for item, marker, owner, foreign_lang in rendered:
note_ids.append(int(item["id"]))
lines.append(_prior_art_line(item, marker, owner, foreign_lang))
if stamped:
lines.append(_stamp_line(path, stamped))
if divergence:
lines.append(_divergence_line(path, divergence))
# 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
# snippet surfaced BY PLACE left no trace anywhere, making the arm that
# fires on the strongest possible claim ("there is already a canonical
# helper in this exact file") the one arm nobody could measure.
# helper in this exact file") the one arm nobody could measure. The sync
# class gets its own tag: its pull-through rate is the number that says
# whether edit-time record-sync actually happens (#2708's success measure).
by_arm: dict[str, list[int]] = {}
if sync_note_ids:
by_arm["write_path_sync"] = list(sync_note_ids)
for marker, item in menu:
arm = "write_path_place" if marker in ("here", "nearby") else "write_path_semantic"
arm = "write_path_place" if marker == "nearby" else "write_path_semantic"
by_arm.setdefault(arm, []).append(int(item["id"]))
for arm, ids in by_arm.items():
record_surfaced(user_id=user_id, note_ids=ids, source=arm)
return {"context": "\n".join(lines), "note_ids": note_ids, "config": cfg}
return {
"context": "\n".join(lines),
"note_ids": note_ids,
"sync_note_ids": sync_note_ids,
"config": cfg,
"stamped": stamped,
"divergence": divergence,
}
def _divergence_line(path: str, divergence: list[dict]) -> str:
"""Button B where button A is canon — named at the write (#2793)."""
parts = [
f"`{('.' if d['kind'] == 'css' else '') + d['symbol']}` → #{d['canon_snippet_id']} "
f"({d['instances']} of {d['judged']} judged siblings are its instances)"
for d in divergence
]
return (
f"> Divergence check at `{path}`: a canon dominates this directory — "
f"{'; '.join(parts)}. If this is a new instance, pull that snippet "
"and build from it; if it is a deliberate departure, "
"`classify_shapes(..., status=\"variant\", reason=…)` records the why; "
"otherwise it reads as unintended divergence."
)
def _stamp_line(path: str, stamped: list[dict]) -> str:
"""One line saying what the ledger just recorded, so the session can
correct a wrong stamp in the moment rather than an audit finding it."""
by_snippet: dict[int, list[str]] = {}
for row in stamped:
label = f".{row['symbol']}" if row["kind"] == "css" else row["symbol"]
by_snippet.setdefault(int(row["snippet_id"]), []).append(f"`{label}`")
parts = [
f"{', '.join(names)} → instance of #{sid}"
for sid, names in by_snippet.items()
]
return (
f"> Shape accounting: recorded at `{path}` — {'; '.join(parts)} "
"(classified_by=hook: you pulled that snippet this session and this "
"code references/resembles it). Not an instance? `classify_shapes` "
"overrides a hook stamp."
)
async def _topic_titles(topic_ids: set[int]) -> dict[int, str]:
+11 -57
View File
@@ -208,54 +208,9 @@ async def get_project_summaries(
async def get_project_summary(user_id: int, project_id: int) -> dict:
"""Return task counts by status, note count, and last activity."""
async with async_session() as session:
# Task counts by status
task_rows = await session.execute(
select(Note.status, func.count(Note.id))
.where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.status.isnot(None),
Note.deleted_at.is_(None),
)
.group_by(Note.status)
)
# Initialise all three lifecycle keys to 0 so consumers can sum them
# safely without `?? 0` guards. Frontend interface declares all three
# as required; rendering `undefined + N` yields NaN.
task_counts: dict[str, int] = {"todo": 0, "in_progress": 0, "done": 0}
for status, count in task_rows.fetchall():
task_counts[status] = count
# Note count (non-tasks)
note_count_result = await session.scalar(
select(func.count(Note.id)).where(
Note.user_id == user_id,
Note.project_id == project_id,
Note.status.is_(None),
Note.deleted_at.is_(None),
)
)
note_count = note_count_result or 0
# Last activity
last_activity_result = await session.scalar(
select(func.max(Note.updated_at)).where(
Note.user_id == user_id,
Note.project_id == project_id,
)
)
from scribe.services.milestones import get_project_milestone_summary
milestone_summary = await get_project_milestone_summary(user_id, project_id)
return {
"task_counts": task_counts,
"note_count": note_count,
"last_activity": last_activity_result.isoformat() if last_activity_result else None,
"milestone_summary": milestone_summary,
}
"""Return task counts by status, note count, and last activity — the
one-project view of get_project_summaries (one rule, not two copies)."""
return (await get_project_summaries(user_id, [project_id]))[project_id]
# ---------------------------------------------------------------------------
@@ -279,8 +234,6 @@ async def list_projects_for_user(user_id: int, status: str | None = None) -> lis
"""Owned projects + shared projects, each dict has 'permission' field."""
from scribe.models.group import GroupMembership
from scribe.models.share import ProjectShare
from scribe.services.access import PERMISSION_RANK
owned = await list_projects(user_id, status)
owned_ids = {p.id for p in owned}
@@ -307,13 +260,14 @@ async def list_projects_for_user(user_id: int, status: str | None = None) -> lis
)
)).scalars().all()
seen: dict[int, str] = {}
for share in list(shared_direct) + list(shared_group):
if share.project_id in owned_ids:
continue
prev = seen.get(share.project_id)
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
seen[share.project_id] = share.permission
from scribe.services.sharing import best_permission_by
seen = {
pid: perm
for pid, perm in best_permission_by(
list(shared_direct) + list(shared_group), "project_id"
).items()
if pid not in owned_ids
}
for pid, perm in seen.items():
if status:
+6 -31
View File
@@ -4,7 +4,7 @@ Every 15 minutes, creates the next occurrence of any recurring task whose spawn
time has arrived — draining `recurrence_next_spawn_at`, which is armed on task
completion. Without this job, recurring tasks would never recur.
Uses the BackgroundScheduler pattern shared with the other *_scheduler modules.
One ScheduledJob (services/scheduler.py), like the other *_scheduler modules.
(Formerly event_scheduler.py, which also ran event reminders + CalDAV sync;
those were removed when the calendar surface was retired.)
"""
@@ -13,52 +13,27 @@ from __future__ import annotations
import asyncio
import logging
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
logger = logging.getLogger(__name__)
from scribe.services.scheduler import ScheduledJob
_scheduler: BackgroundScheduler | None = None
_loop: asyncio.AbstractEventLoop | None = None
logger = logging.getLogger(__name__)
async def _run_recurrence_spawn() -> None:
from scribe.services.recurrence import spawn_recurring_tasks # noqa: PLC0415
try:
await spawn_recurring_tasks()
except Exception:
logger.warning("Recurring-task spawn job failed", exc_info=True)
def _run_recurrence_spawn_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
asyncio.run_coroutine_threadsafe(_run_recurrence_spawn(), loop)
_JOB = ScheduledJob("recurrence_spawn", _run_recurrence_spawn, label="Recurring-task spawn")
def start_recurrence_scheduler(loop: asyncio.AbstractEventLoop) -> None:
global _scheduler, _loop
if _scheduler is not None:
return
_loop = loop
_scheduler = BackgroundScheduler()
# Spawn the next occurrence of due recurring tasks every 15 minutes.
# Without this job, recurrence_next_spawn_at is armed on completion but
# never drained, so recurring tasks never recur.
_scheduler.add_job(
_run_recurrence_spawn_threadsafe,
trigger=IntervalTrigger(minutes=15),
args=[loop],
id="recurrence_spawn",
replace_existing=True,
)
_scheduler.start()
logger.info("Recurrence scheduler started (recurring-task spawn every 15m)")
_JOB.start(loop, IntervalTrigger(minutes=15), describe="recurring-task spawn every 15m")
def stop_recurrence_scheduler() -> None:
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("Recurrence scheduler stopped")
_JOB.stop()

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