Compare commits

...
205 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 6be44013bb fix(6872): a verdict or write-path stamp on a vanished shape revives its row instead of inserting a duplicate
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m46s
CI & Build / integration (push) Successful in 1m42s
CI & Build / Build & push image (push) Successful in 18s
classify_shapes(repo=...) and the write-path hook looked only at live rows,
so a shape whose row had vanished got a second provisional row under the
same (project, repo, path, symbol, kind). The unique constraint refused it,
and the whole classify batch was lost as a tool error.

provisional_row() now finds the row under that identity, vanished or not.
A vanished row is revived with a reappeared event and keeps its judgment.
A new row is inserted inside a savepoint, so a concurrent insert of the
same identity falls back to that row instead of failing the transaction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:02:13 -05:00
bvandeusenandClaude Opus 5.5 219bfeac42 docs(6868): the proxy count is set in Settings, with the env var as the starting value
Configuration, the README env table and the features page say Settings
-> Config -> Reverse proxy is where the count is checked and set,
that a saved count wins over TRUSTED_PROXY_HOPS, that Cloudflare in
front of your own proxy is 2, and what a too-low count does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:03:22 -05:00
bvandeusenandClaude Opus 5.5 d2267b615f feat(6867): Settings → Config has a Reverse proxy section that shows which count is right
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m46s
CI & Build / integration (push) Successful in 2m8s
CI & Build / Build & push image (push) Successful in 33s
The section opens with a verdict on the admin's own request: the
address Grimoire reads them as, or, through Cloudflare, the count to set
with a button that sets it. Below it are the count with where it came
from, a table of what counts 0-3 would read, and the raw forwarding
chain. A saved count can be cleared back to the environment's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 09:59:05 -05:00
bvandeusenandClaude Opus 5.5 753a6ccb31 feat(6866): the proxy hop count is an admin setting, and the server shows what each count reads
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / integration (push) Successful in 2m1s
CI & Build / Build & push image (push) Successful in 20s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / Python tests (push) Successful in 1m47s
An admin can save how many reverse proxies stand in front of Grimoire.
A saved count wins over TRUSTED_PROXY_HOPS, which stays the starting
value. Each worker loads it at startup (under a deadline) and re-reads
it every 30s; the worker that saves applies it at once.

GET/PUT /api/admin/proxy returns the count, where it came from, and a
readout of the admin's own request: the socket peer, the forwarding
chains, what counts 0-3 would read, and the count that reads
Cloudflare's visitor when Cloudflare's headers are present. Saves are
audit-logged. A "too many attempts" reply now says where to look when
everyone is being blocked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 09:55:42 -05:00
bvandeusenandClaude Opus 5.5 49b5ceee41 docs(6856): the scope section says the cross-project rule in fewer lines, so using-grimoire fits its 350-line budget
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m52s
CI & Build / integration (push) Successful in 2m26s
CI & Build / Build & push image (push) Successful in 17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:01:22 -05:00
bvandeusenandClaude Opus 5.5 8f8263b347 docs(6856): the plugin's guidance and the docs say what a session may do in another project
CI & Build / TypeScript typecheck (push) Successful in 31s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python tests (push) Failing after 1m29s
CI & Build / integration (push) Successful in 2m21s
CI & Build / Build & push image (push) Skipped
using-grimoire, which owns cross-project conduct, now says the one change
to make toward another project is filing an issue with how it was found
and the evidence, and that the plugin asks before anything more. The
features page describes the fence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:52:09 -05:00
bvandeusenandClaude Opus 5.5 ddcac96542 fix(6855): the fence keeps its files where no compaction sweeps them, and builds id lists under mawk
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / Python tests (push) Successful in 1m52s
CI & Build / integration (push) Successful in 2m21s
CI & Build / Build & push image (push) Successful in 22s
The project a session entered now lives in grimoire-fence, which the
compaction clear never visits, and session end removes it. Arrays of ids
in a tool's input were sent as [,"3","4"] under mawk, which creates an
array element as soon as it is assigned to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:51:34 -05:00
bvandeusenandClaude Opus 5.5 805fda5cb1 feat(6855): the plugin asks before a session writes to a project other than its own
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Failing after 1m27s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 2m1s
plugin/hooks/grimoire_project_fence.sh runs as PreToolUse on editor writes,
Bash and Grimoire's tools. A session belongs to the project its starting
directory resolves to (CLAUDE_PROJECT_DIR: a .grimoire marker, else the git
remote's binding). A session that starts outside any project belongs to the
first one it enters, which PostToolUse on enter_project records.

These acts stop on permissionDecision "ask", with the server's reason naming
the other project:
- an edit in another project's repo or a worktree of it;
- a git commit, push, merge, rebase, pull, cherry-pick, revert, tag, am, reset
  or worktree add there, found through git -C, a cd before it, or the shell's
  current directory;
- a Grimoire write that lands in another project.

A live probe showed "ask" holds in bypassPermissions. Filing an issue and
every read go ahead. An edit or git command in the session's own repo, and any
tool named like a read, never calls the server. Grimoire writes send only
their short arguments, so the URL stays small.

When the instance doesn't answer, the act goes ahead and the hook says once
per outage that it couldn't check. check_plugin.py now holds one outage line
per hook and has a smoke event for the fence.

GET /api/plugin/write-scope takes fallback_project_id for the entered project.

tests/test_project_fence.py drives the hook against real git repos and a
stand-in server. tests/test_write_scope.py holds the hook's
FENCE_READ_PREFIXES equal to services/write_scope.READ_PREFIXES. The plugin
README and PACKAGING describe the fence, and the plugin is minted to
2026.11.05.0245.

Milestone 583, step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:45:37 -05:00
bvandeusenandClaude Opus 5.5 16b1d68ebd fix(6854): the locating arguments that only point are declared as references, and the plugin version is minted
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / Python tests (push) Successful in 1m43s
CI & Build / integration (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 22s
The guard caught seven write-tool arguments with a locating name that are not
where the write lands:
- snippet_id on classify_shapes_by_rule and confirm_shape_proposals names the
  canon, not the rows written;
- rule_outcome, rule_misfired and judge_lesson_link record judgments.

REFERENCES lists each one with its reason, and the guard reads it.
READ_PREFIXES names the tool prefixes the fence answers without the server,
and a test holds every write tool to a name outside them.

plugin.json is minted to 2026.11.05.0242 for the PACKAGING.md row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:42:14 -05:00
bvandeusenandClaude Opus 5.5 55f0f96016 feat(6854): the server says which project a pending act would change, and whether this session may do it
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Failing after 1m23s
CI & Build / Build & push image (push) Skipped
CI & Build / Plugin hooks (push) Failing after 11s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / integration (push) Successful in 1m53s
GET /api/plugin/write-scope answers the plugin's fence. The session's project
comes from repo / project_id, as every plugin endpoint reads it. The act is
either:
- a directory (target_repo / target_project_id), for a file edit or a git
  command; or
- an MCP call (tool, plus input: the call's short arguments).

It returns allowed, the other projects the act would change, and the reason
the operator is asked. It is a GET so a read-scoped key still fences file
edits and git.

services/write_scope.WRITE_TARGETS lists every write tool with the arguments
that locate its write: a project id, or a task, note, milestone, System, rule,
relation, trash batch, repo or family decision, each traced to its project. A
write that lands in no project (install settings, user-level records, the
judgments a session is asked for anywhere) has an empty entry. The one
cross-project write allowed is create_task(kind="issue") as a new, unplanned,
open issue. A project the caller cannot read is never named.

tests/test_write_scope.py:
- requires an entry for every write tool;
- requires every declared argument to exist on the tool;
- refuses a locating argument (project_id, task_id, milestone_id and the
  rest) that a write tool takes without declaring it.

The integration test traces each locator from real rows.

Milestone 583, step 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:39:03 -05:00
bvandeusenandClaude Opus 5.5 373f5bd454 feat(6839): the decision log and the knowledge feed load as you scroll, through one ScrollSentinel
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / integration (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 36s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 33s
ScrollSentinel sits at the foot of a server-paged list and asks for the
next page a screen before the reader reaches it. It asks once per cursor,
forgets the cursor when its element goes, and shows Try again on a failed
page instead of retrying in a loop.

FamilyView's decision log loses its "Older decisions" button. A failed
page shows in place rather than as a toast, and rows already shown are
skipped when a new decision shifts the offset.

KnowledgeView drops its inline IntersectionObserver for the component.
The cursor is the ids consumed, not the items rendered, so a batch that
returns fewer records than it asked for still moves the feed on. A failed
batch puts its ids back and offers Try again, where it used to fail
silently and stop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:14:41 -05:00
bvandeusenandClaude Opus 5.5 846c87629f test(6849): the restore command previews without --yes and rolls back from the tar with it
CI & Build / Python lint (push) Successful in 1s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m39s
CI & Build / integration (push) Successful in 1m51s
CI & Build / Build & push image (push) Successful in 16s
Two integration tests in test_integration_backup_rollback.py drive
grimoire.restore.run on the scheduled backup's tar, written from a full
export of this instance:
- without --yes it exits 4 and nothing changes;
- with --yes it takes the safety backup, then puts back the edited and the
  deleted task and removes the new one.

The file-restore test that expected a refusal "because the database has
accounts" now names the reason it actually refuses: on a live instance, a
backup that records no instance id can't be rolled back to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:12:30 -05:00
bvandeusenandClaude Opus 5.5 37be3b0bff feat(6849): the command-line restore uses the same rollback, and the docs say how restore works now
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 31s
CI & Build / Python tests (push) Successful in 1m39s
CI & Build / integration (push) Successful in 1m49s
CI & Build / Build & push image (push) Successful in 31s
python -m grimoire.restore <file> prints what the restore would change (the
same preview as Settings) and stops. With --yes it runs
backup_job.rollback_to_backup: the current state is saved first, then the
whole instance goes back to the backup. An empty instance takes any full
backup. A live one only takes a backup of its own, and otherwise exits 3 with
the reason. A v1 backup keeps the copy-in path into an empty instance.

docs/backups.md Restoring is rewritten:
- what a restore puts back and removes;
- the same-instance rule;
- the safety backup;
- what survives;
- the web and command-line ways in.

Its S3 key advice now asks for read as well as write when restoring from the
bucket. README, features, architecture and the API reference follow.

Milestone 582, step 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:08:31 -05:00
bvandeusenandClaude Opus 5.5 1f5e963c35 feat(6848): Settings restore rolls back, from the destination or a file, after a preview
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / Python lint (push) Successful in 1s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m42s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Build & push image (push) Canceled after 22s
The web restore no longer copies a backup in beside the live data.

- GET /api/admin/restore/sources lists the backups at the backup destination,
  newest first.
- POST /api/admin/restore/preview says what a restore would do, changing
  nothing: fresh or rollback, when the backup was taken, and counts now and
  after per kind. A backup that cannot apply here gets a 409 with the reason.
- POST /api/admin/restore?confirm=rollback runs backup_job.rollback_to_backup:
  safety backup, replace, then search rebuilt in the background.

Each takes ?source=<name> (a backup at the destination) or an upload. The
destination path exists because uploads are held to the request-size limit,
and a real instance's backup is far over it. backup_s3.get_file downloads
from a bucket. backup_destination.list_backups/open_backup only accept names
the scheduled backup writes, so a request cannot reach any other file.

Settings -> Data -> Restore from Backup opens RestoreBackupDialog. The dialog:
1. Pick a backup at the destination or a file.
2. Read the preview.
3. Type "roll back".
4. Run it. The dialog then says where the replaced state was saved, and sends
   the user to sign in again.

Closes #6840. Milestone 582, step 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:06:07 -05:00
bvandeusenandClaude Opus 5.5 34f8d7060b feat(6847): a rollback first backs up the state it replaces
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / integration (push) Successful in 1m49s
CI & Build / Python tests (push) Successful in 1m40s
CI & Build / Build & push image (push) Successful in 24s
backup_job.rollback_to_backup:
1. Checks the backup can apply here (backup.check_replace, which shares
   _replace_mode with replace_from_backup), refusing before anything is written.
2. Takes the backup lock, so no scheduled backup runs during the rollback.
3. Saves the current store with save_state_before_restore.
4. Replaces the store.

The safety file goes to the backup destination when backups are on and it
takes it. Otherwise it goes to DATA_DIR/pre-restore, with a note when the
destination refused. If neither takes it the rollback does not run. The
result names where the file went.

Milestone 582, step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:02:26 -05:00
bvandeusenandClaude Opus 5.5 8806ab85b4 feat(6846): replace_from_backup rolls the store back to a full backup by id
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / integration (push) Successful in 2m1s
CI & Build / Build & push image (push) Successful in 20s
One transaction:
- Refuse unless the backup is a full one and, on a live instance, carries
  this instance's id. A fresh instance (no records, at most the restoring
  account) takes any full backup and adopts its id.
- Clear first: search vectors and the CSS consumer map (derived); every
  backed-up table nothing outside the backup points at, whole; the settings
  the backup carries; and the identity-table rows the backup lacks. A share
  granted by a removed user goes before the user.
- Write the backup through the same _build_* helpers as the copy-in restore,
  now _load_v2(keep_ids=...). Identity tables (users, projects, milestones,
  notes, rulebooks, topics, rules, systems, design systems, code shapes,
  family decisions) go through _place. That keeps the backup's id and
  merges, so API keys, shares and forge connections of a surviving row stay.
- Reset id sequences past the restored max, and sign every session out.

reindex_after_restore runs the four embedding backfills afterwards. A unit
guard checks that no row kept for outside references cascades from a table
replaced whole. Integration tests cover edits, deletions and new rows put
back, a surviving API key, sessions ending, fresh ids afterwards, foreign or
id-less backups refused, a user export refused, a failure part-way changing
nothing, and a fresh instance adopting the id.

Milestone 582, step 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:59:48 -05:00
bvandeusenandClaude Opus 5.5 6d8db0bb65 feat(6845): every instance has an id lineage, and every full backup carries it
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / integration (push) Successful in 2m7s
CI & Build / Build & push image (push) Successful in 22s
instance_identity is one row made by migration 0127 with a random id, so an
instance has it before its first account. A full backup carries it in its
header as instance_id (the tar manifest header and the JSON top level);
BACKUP_VERSION is 28. The table rides in _NOT_INCLUDED: a restore decides from
the header value rather than copying a row.

Two stores with the same id number their rows the same way. That is what lets
a later step roll a live instance back by original id, refuse a backup whose
ids would land on unrelated rows, and have a fresh-instance restore adopt the
backup's id (adopt_instance_id).

Milestone 582, step 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:53:45 -05:00
bvandeusenandClaude Opus 5.5 3c01e17559 test(6838): give the test request its socket peer the way Quart reads it
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m38s
CI & Build / integration (push) Successful in 2m16s
CI & Build / Build & push image (push) Successful in 38s
Quart takes remote_addr from a Remote-Addr header its ASGI layer writes from
the scope (replacing any the client sent). A test request context does not
read scope_base for it, so the two socket-peer tests saw an empty address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:37:28 -05:00
bvandeusenandClaude Opus 5.5 dbd6d5b8b5 feat(6838): read the client address and HTTPS from the trusted proxy hop
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Failing after 1m20s
CI & Build / integration (push) Successful in 2m27s
CI & Build / Build & push image (push) Skipped
grimoire/proxy.py is the one reader of X-Forwarded-For and X-Forwarded-Proto.
It takes the entry TRUSTED_PROXY_HOPS in from the right (default 1; a shorter
header reads its leftmost entry; 0 trusts no proxy), so an entry a client puts
left of the proxy's is never believed. Before this, with TRUST_PROXY_HEADERS
on, the leftmost X-Forwarded-For entry keyed the login, register, forgot and
reset rate limits and the audit log, and a client could choose it.

The session cookie is Secure, and HSTS (max-age=31536000) is sent, exactly
when the request is HTTPS by the trusted proxy's word. Plain HTTP is
untouched and nothing redirects. SECURE_COOKIES is gone (a startup warning says
so when it is still set); TRUST_PROXY_HEADERS=false still maps to 0 hops, with
a warning to move to TRUSTED_PROXY_HOPS. Admin audit lines now use the same
client address instead of the socket peer.

The placeholder-key refusal keyed on SECURE_COOKIES, so it now refuses either
placeholder key outright; the instance makes its own key when none is set.

Family idea #5004 (IsHTTPS, trusted-hop X-Forwarded-Proto), canon v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:32:20 -05:00
bvandeusenandClaude Opus 5.5 b87178d261 fix(6495): a name defined in two packages counts only in the canon's own
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / integration (push) Successful in 1m22s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Successful in 1m59s
CI & Build / Build & push image (push) Successful in 43s
A by-name reference edge could not tell two definitions of one name apart:
in a Go repo that defines `file` as importRun.file (internal/files, the
canon) and as sftpRun.file (internal/replication), a mirror test calling
`m.file(` was recorded as a use of the canon.

names_canon now takes defined_at, every repo path that defines each name,
in place of the shape file's own names. The own-file rule is unchanged.
When the repo also defines the canon's name somewhere the canon is not
recorded, a mention counts only from the canon's own package: the
directory of a file location exactly, or under a directory location.
A name defined once resolves as before. Proposer version 8 -> 9, so the
next coverage refresh re-reads every row and replaces its reference edges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:05:09 -04:00
bvandeusenandClaude Opus 5.5 2a6d9080ba fix(6477): a plain-word call needs the parenthesis right after the word
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / Build & push image (push) Successful in 26s
Read against Fabled Archives after the cleanup: of the 9 rows still linked
to snippet #5448 (symbol `file`), all but one matched prose in a comment,
"a file (not a directory)", "the file (ScanWaiting". The only real call
was `r.file(ctx, ...)`. Formatted code never puts a space before a call's
parenthesis and prose does, so the plain-word test is now `word(` with no
whitespace. _PROPOSER_VERSION 8 re-reads every row once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:07:03 -04:00
bvandeusenandClaude Opus 5.5 3e2227e8c5 fix(6483): drop the cross-project uses edges the write path wrote
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 1m0s
CI & Build / Python tests (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 19s
Migration 0125 removed the proposer's cross-project `reference` edges, but
the write path records its by-name evidence with basis `hook`, so those
survived it. A hook edge across projects is impossible for the same reason a
reference edge is, and the write path now applies the same by-name test
(names_canon), so 0126 removes the ones written before. Judged edges keep
their own basis and are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:29:54 -04:00
bvandeusenandClaude Opus 5.5 cdb71e64c4 fix(6477): a body uses a canon by name only when the name can resolve to it
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 43s
CI & Build / integration (push) Successful in 1m36s
CI & Build / Python tests (push) Successful in 2m7s
CI & Build / Build & push image (push) Successful in 42s
The proposer's reference basis, and every uses edge it writes, counted any
word-bounded mention of a canon's symbol. Three holes made that wrong at
scale (snippet #5448, symbol `file`, held 2,161 uses edges):

- Go, Rust, Kotlin, Swift, Dart, C, C#, Ruby and PHP were outside the
  language-family maps, so their canons read as "unknown, no gate" and were
  matched in every Python, TS and CSS body.
- Canons in other projects were matched by name, though one repo never
  calls a function defined in another's.
- A plain word (`file`, `seen`) or a module's own helper (`_run`) matched
  wherever it appeared.

names_canon is now the one by-name test, used by match_canon,
reference_canons and the write-path hint: same project, not a name the file
defines itself (unless the canon is recorded there), no generic verb with or
without a leading underscore, and a bare lowercase word only where it is
called. The write path keeps the payload's own names in play, since a
redefinition of a pulled canon is the copy it exists to flag.

Repair: record_uses only adds, so replace_reference_uses now makes a row's
machine-written reference edges exactly what the current rule finds,
leaving judged and hook edges alone. _PROPOSER_VERSION 7 re-reads every row
on its project's next refresh. Migration 0125 deletes the cross-project
reference edges already stored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:41:21 -04:00
bvandeusenandClaude Opus 5.5 0b98a3a5cd chore(6480): remove five Vue components nothing imports
HistoryPanel, InlineAssistPanel, NoteCard, WorkspaceNoteEditor and
WorkspaceTaskPanel had no importer, lazy import or global registration
anywhere in frontend/src. The features they served were replaced; token
sweeps kept restyling them because they match a glob. The two doc comments
that named HistoryPanel.vue now name VersionHistorySection.vue, the note
history that is live.

SearchBar.vue stays: it is the recorded home of snippet #2961 "SearchBar -
the search input recipe", and the two live search boxes are judged variants
of it. Deleting it would leave the recipe pointing at nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 17:36:03 -04:00
bvandeusenandClaude Opus 5.5 7faaa311d7 fix(6470): a moment proposal counts only opens no hook explained, and asks the reader to judge it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m46s
CI & Build / Build & push image (push) Successful in 37s
The signal counted every get_rule open against every moment from the
previous three minutes, so a rule a search arm had just shown was
proposed for whatever moment happened to fire. All nine pending signal
proposals were wrong.

- The hook skips an open of a rule a hook named this session, and sends
  the last three acts from 90 seconds instead of 30 from 180.
- A rule already mounted anywhere gathers no signal evidence.
- A signal pair keeps the actions that reached its moment and shows them
  as its why.
- The proposal line says it is co-occurrence, not a recommendation, and
  asks the reader to check the rule against the moment before offering
  it; moments.md says the same.

Plugin 2026.10.26.2029.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:29:40 -04:00
bvandeusenandClaude Opus 5.5 83cb6420e2 fix(6471): the end-of-turn shape check hears every definition a write made
The write hooks recorded at most 12 definitions per write (alphabetical,
so test functions were the ones dropped) and the post-Bash hook stopped
after 4 files, so the Stop hook never asked about the rest. The caps
bound the prior-art query; the written ledger now gets the full list,
up to 40 files per Bash call, while the query stays at 4 files x 12 names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:26:15 -04:00
bvandeusenandClaude Opus 5.5 db2427a3dc feat(6249): the write-path hint asks whether a new definition copies a close snippet
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Successful in 1m56s
CI & Build / Build & push image (push) Successful in 22s
The derive check matches by name, so a renamed copy of a recorded snippet
passed it and showed only as "similar" prior art. When a write introduces a
definition the file does not hold (shape_ledger.new_shapes_at) and an unseen
snippet on the menu clears the write-path floor by WRITEPATH_COPY_MARGIN (0.80
at the default floor), the hint now says to reuse it or extract the shared
shape before another copy. Names the derive check or the stamping feed already
spoke for are left to them. Covers recorded snippets only: the ledger keeps no
bodies to compare an unrecorded sibling against.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:14:41 -04:00
bvandeusenandClaude Opus 5.5 0a4eb313fe fix(6255): Test destination names an address that did not answer as S3
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m5s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 23s
A 4xx with no S3 error body came from something else at that address, most
often a web server on the default port. The message now says so and to check
the scheme and port, beside the raw status. A 5xx without a body keeps its
own reason, since a busy S3 service can answer that way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:03:52 -04:00
bvandeusenandClaude Opus 5.5 d51ccd9b69 fix(6253): the moment-proposal queue lists a signal pair only once it has proposed
pending() listed every suggested signal row, including pairs still gathering
evidence below the three-situation bar, so the queue held 200 "proposals"
where about 25 had crossed it. A signal pair now needs proposed_at in its
evidence, the same test the misfire half already applies; proposals from a
pass are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 20:03:52 -04:00
bvandeusenandClaude Opus 5.5 67d3c68fbf feat(6254): moment_usage says which rule each moment delivered, and whether it was opened
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m5s
CI & Build / Python tests (push) Successful in 1m57s
CI & Build / Build & push image (push) Successful in 33s
Each moment gains by_rule: {rule_id, title, delivered, opened}, read from
the (moment, rule) grouping the readout already did. The moment's totals are
now summed from those rows, so the two cannot disagree. The Moments settings
page shows the list under each moment, closed by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:59:02 -04:00
bvandeusenandClaude Opus 5.5 95ef4af746 fix(6251): the rules warning sends its reader to the rule-arm review
surfaced_never_pulled told a rules reader there was no judged sample for the
rule arms. There is one now; the guard tying the text to REVIEWABLE caught it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:59:02 -04:00
bvandeusenandClaude Opus 5.5 7c1d98dd2a feat(6251): the rule arms can be reviewed — menus_to_review re-runs prompt_rule, pre_tool_rule and write_path_rule
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Failing after 1m26s
CI & Build / Build & push image (push) Skipped
A rule arm logs its query, floor, budget and project, which is everything
semantic_search_rules takes, so a logged call is re-run to a depth past its
budget and judged like an auto_inject menu. within_budget is the arm's own
cut: inside the budget and, for the banded command and write arms, inside the
band of the top hit. "Opened after" reads rule pulls, and rules created
after the call are dropped as notes are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:56:17 -04:00
bvandeusenandClaude Opus 5.5 4297c88cd2 fix(6252): the named-record test keeps its number after "with"
"in 4448" is not read as a reference, so the subject went after the number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:56:17 -04:00
bvandeusenandClaude Opus 5.5 dac4c59e07 fix(6252): a prompt that names nothing gets no notes menu
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Failing after 1m26s
CI & Build / Build & push image (push) Skipped
"keep going", "yes please do", "is it deployed?": with no word of its own the
auto-inject query was the previous reply's tail alone, and the menu handed
back the records that reply was built from. In a sample of 59 logged calls,
28 came from short prompts; those with a subject of their own ("continue into
DRY 14", "fix the Windows gate") led with the record meant, while the ones
without were finished tasks already in context, with an open on 2 of about 15.

_has_subject checks the operator's words against a list of words that name
nothing; without one, the ranked menu is skipped and records named by number
are still shown. The prompt rule arm is unchanged and still searches with the
tail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:51:58 -04:00
bvandeusenandClaude Opus 5.5 1fa9245bbf fix(6247): a getter is seen by its pulls, so the silent list stops naming every pull door
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m5s
CI & Build / Python tests (push) Successful in 1m56s
CI & Build / Build & push image (push) Successful in 22s
usage and rule_usage gain pulled_by_source, read from the per-source rows
the readout already had, and _silent_surfaces counts those doors as having
emitted. The rules-listing points were seen as of the previous commit,
through rule_usage.by_source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:28:29 -04:00
bvandeusenandClaude Opus 5.5 d05d4f3632 feat(4636): rule pull-through per surface, and per-rule usage with confirmed lessons on list_rules
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Successful in 1m56s
CI & Build / Build & push image (push) Successful in 22s
retrieval_telemetry gains rule_usage.by_source, the twin of usage.by_source:
distinct rules each surface showed, how many an agent opened, and a ratio
that is null on a bulk delivery. list_rules(with_usage=True, days=N) adds
each rule's usage, windowed, and how many lessons are confirmed instances
of it, so linked and unlinked rules can be compared through MCP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:20:32 -04:00
bvandeusenandClaude Opus 5.5 71260a28e2 align(4909b): every write with a path gets its rule check, whether or not prior art matched
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m56s
CI & Build / Build & push image (push) Successful in 22s
The write-path rule arm ran only past the notes guard: a write whose code
matched no snippet, issue or lesson returned before any rule was searched.
It now runs before that guard, on `code or path`, and a write that matched
nothing but a rule still returns the rule lines.

Operator ruling (2026-10-05): yes. A rule about the file being written does
not depend on a snippet resembling it.

Cost: one more rule search per write that matched no prior art, with its
own embedding (the query is `code or path`, not the notes arm's concept
query, so the per-request memo does not cover it). Plus the preference slot
from (c) on those writes.

Expected telemetry shift, to be read on its own once this reaches main:
write_path_rule calls rise toward write_path's count (14 days to 2026-10-22:
write_path_rule 4,634 against write_path 3,786 — the notes arm skips trivial
payloads, the rule arm does not, so the rule arm will now exceed it). Its
zero-result share rises too, since the newly covered writes are the ones
that matched nothing in the notes corpus.

Tests: the gate test is inverted (a write with no prior art still gets its
rule check); the design-only write now runs the rule arm on its path; two
notes-arm tests read the write_path row instead of any row, since the rule
arm writes its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:42:14 -04:00
bvandeusenandClaude Opus 5.5 407f594a53 fix(4909c): the tool-arm suppression tests read the arm's own row, not the slot's
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:39:27 -04:00
bvandeusenandClaude Opus 5.5 c3933507c9 align(4909c): the command and write rule arms reserve a line for a preference
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Failing after 1m26s
CI & Build / Build & push image (push) Skipped
PRE_TOOL_RULE and WRITE_PATH_RULE now take preference_slot=True, like
PROMPT_RULE. When no preference placed on score, the arm asks once more for
the best preference over the same bar, and shows it as one extra line.

Operator ruling (2026-10-05): yes. A preference about how to act ("let each
action land before the next") is ranked against every rule whose trigger
names the same command; the act is its last chance to shape anything.

Cost: one kind-filtered search per call that placed no preference. The
query embedding is memoised per request on both hook routes, so no extra
model call. Expect a new stream of preference_slot rows from the two act
arms; preference_slot's call count will rise roughly by pre_tool_rule's and
write_path_rule's (~23k calls over 14 days before this change), and its
zero-result share will rise with it, since most commands have no matching
preference.

Tests: the tool-arm call-row tests count rows under pre_tool_rule only (the
slot logs under its own name), and the write-arm bar test reads the arm's
own search, which is the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:37:22 -04:00
bvandeusenandClaude Opus 5.5 6299335254 fix(4909d): a shared floor move records the named surface first, then its siblings
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m58s
CI & Build / Build & push image (push) Successful in 33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:34:16 -04:00
bvandeusenandClaude Opus 5.5 c8ede427ca align(4909d): every rule arm that annotates reads one bar, kb_rule_threshold at 0.70
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Failing after 1m30s
CI & Build / Build & push image (push) Skipped
prompt_rule, pre_tool_rule and write_path_rule now share one settings key,
RULE_FLOOR_KEY = "kb_rule_threshold", default 0.70. The reply backstop keeps
its own 0.80, because there the floor is a stop bar. Moving the shared floor
through tune_retrieval or the Settings form records a tuning event on every
surface that reads it (set_dial, via retrieval_surfaces.sharing_floor), and
the result names the surfaces it carried as `moved_with`. Settings shows one
"Standing-rule confidence threshold" control where it showed three.

Operator ruling (2026-10-05): one shared bar for the rule arms, "especially
since we're only taking the first x number of them" -- the budget limits
what is shown; the bar only decides what is worth ranking. Kept a dial.

Why 0.70, from records read rather than percentiles:
- write_path_rule near-misses at 0.7199-0.72 were all false positives
  (rule 149 against a Lidarr comment, 157 against a retention Svelte file,
  121 against an Archives restore, 164 against a config test, 131 against a
  Go package), so the old 0.72 was not hiding real hits -- but the band does
  noise control on this arm, so dropping 0.02 costs little.
- prompt_rule: the operator set 0.70 by hand on 2026-09-18, after a 0.60
  experiment admitted junk and was reverted. 0.70 is the number with a human
  decision behind it.
- pre_tool_rule lines near 0.68 this session were mostly noise; rising to
  0.70 trims them, and the band still narrows what clears.

Retired keys: kb_promptrule_threshold, kb_toolrule_threshold and
kb_rulehint_threshold are no longer read. Rows an install stored under them
stay in the settings table, inert. On this instance the effective bars move
pre_tool_rule 0.68 -> 0.70, write_path_rule 0.72 -> 0.70, prompt_rule stays
0.70; record_release_defaults writes those per-surface release rows on boot,
so the telemetry shift has an event to explain it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:32:09 -04:00
bvandeusenandClaude Opus 5.5 f77d18cb63 fix(6245): a re-run of bind_repo(moved_from=…) finishes an earlier move
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 59s
CI & Build / integration (push) Successful in 1m25s
CI & Build / Python tests (push) Successful in 2m20s
CI & Build / Build & push image (push) Successful in 32s
Once the binding has moved, the old remote is no longer bound, and
move_binding refused. An install that renamed its repo before snippet
locations moved with it had no way to move them. When the old remote is
unbound and the new one is bound to this project, bind_repo now skips the
binding move and still moves the snippet locations that name the old
repo.

The snippet-move test gives each location its own path: rewritten to one
repo, identical locations merge into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:21:19 -04:00
bvandeusenandClaude Opus 5.5 e89e6a24de fix(6245): bind_repo(moved_from=…) moves the snippet locations recorded in the old repo
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 58s
CI & Build / integration (push) Failing after 1m16s
CI & Build / Python tests (push) Successful in 2m33s
CI & Build / Build & push image (push) Skipped
A repo renamed on its forge kept every snippet location naming the old
repo: the binding and the shape ledger moved, the snippets did not.

- snippets.move_location_repo rewrites, on every live snippet of the
  project, each location whose repo is the old remote in any form (URL,
  normalized key, "owner/Repo", bare "Repo", compared without case) to
  the new normalized key. A snippet the caller can only read is listed
  in read_only and left alone.
- Its loop is shared with move_locations through _rewrite_locations,
  which saves each changed snippet through update_snippet.
- bind_repo calls it after the binding moves and reports snippets_moved
  and snippets_read_only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:18:03 -04:00
bvandeusenandClaude Opus 5.5 787685ac63 fix(6244): the instance keeps its own SECRET_KEY in /data, and docker-compose.yml is the compose users deploy from
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 2m7s
CI & Build / Build & push image (push) Successful in 30s
With no SECRET_KEY set, Grimoire fell back to a fixed placeholder. The
quickstart compose substituted change-me-in-production, so the README's
own update step (pull, up -d) recreated the container on that placeholder,
and every credential sealed under the key the user started with stopped
opening.

- config: with neither SECRET_KEY nor SECRET_KEY_FILE, the first start
  writes a random key to DATA_DIR/secret_key (mode 0600, published with a
  hard link so concurrent starts agree), and later starts read it back.
  An explicit key wins and leaves the file alone. A missing
  SECRET_KEY_FILE or an empty key file stops the start instead of
  switching keys. With no data directory (a run outside the container)
  the process gets a temporary key and says so.
- app: warns on a public placeholder key, on a temporary key, and when
  /data is not a mounted volume.
- Dockerfile creates /data.
- docker-compose.yml is now the deploy compose (published image, both
  volumes, nothing to set). The build-from-source compose is
  docker-compose.dev.yml, and docker-compose.quickstart.yml is gone. The
  Swarm stack file gains the /data volume and keeps its explicit key,
  because a Swarm can move the app away from a node-local volume.
- README: a Deploy section with the compose inline, SECRET_KEY optional,
  "keep both volumes", and updates that keep the key. A test holds the
  README's copy equal to the file.
- Installs that ran on the change-me-in-production placeholder get a key
  of their own on the new compose; Settings then asks once for saved
  forge tokens and the backup secret.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:13:43 -04:00
bvandeusenandClaude Opus 5.5 81fe851509 licence: Fabled Grimoire is AGPL-3.0-only
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 2m4s
CI & Build / Build & push image (push) Successful in 47s
LICENSE carries the GNU Affero General Public License v3.0 text, the same
file the other Fabled projects ship. pyproject.toml, the frontend's
package.json and lockfile, and the plugin manifest declare it as the SPDX
expression AGPL-3.0-only, and the README says what it asks of anyone who
runs a modified version over a network. Reading `license` as an SPDX
expression needs setuptools 77, so the build floor rises to it. The
project description now matches the README's tagline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:45:11 -04:00
bvandeusenandClaude Opus 5.5 92a15d3e1a rename(547): the header draws the clasped grimoire, and the README takes the standard shape
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 42s
AppLogo still drew the old open book with a spark, so the new mark showed
only in the browser tab. It now draws the same clasped grimoire as
favicon.svg, in the mark's own fixed colours. That covers the header and
the sign-in, register and password pages.

The README follows the house order: the logo, name and tagline, a short
summary, quick start including the plugin install, the settings a reader
must set, setup pitfalls, updating and backups, features grouped by what
you do, troubleshooting by the error text, development, and licence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:44:03 -04:00
bvandeusenandClaude Opus 5.5 979efe6662 feat(6243): move_path carries a moved path's snippets with its shapes
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 2m12s
CI & Build / Build & push image (push) Successful in 31s
Snippet locations are keyed by path too, so a snippet recorded under a
moved directory reads `missing` at its next check. The tool is now
move_path(project_id, from_path, to_path). It moves the ledger rows as
before, and re-paths the project's snippet locations under from_path
through update_snippet, so each body and its indexed mirror stay one
record. A snippet the caller can only read is listed in
snippets_read_only. The tool refuses only when nothing at all is recorded
under from_path. The path test is the one snippets already uses
(_path_touches), and the plugin version is minted for the skill edit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:07:32 -04:00
bvandeusenandClaude Opus 5.5 47b52197aa feat(6243): a directory moved in the tree keeps its shape judgments
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Failing after 15s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m15s
CI & Build / Python tests (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 30s
Ledger rows are keyed by path, so after a directory moves the next sync
marks every judged row vanished and lists the same shapes again, unjudged.
move_shapes(project_id, from_path, to_path) re-keys the rows under one path
to the other, in every repo of the project, keeping each row's judgment,
uses edges and history and adding a `moved` event that records the path it
left. A row already at the target is replaced when nobody judged it; a
judged one stands, and the old row is reported in `kept`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:03:03 -04:00
bvandeusenandClaude Opus 5.5 5ad1df8e0f feat(6242): a repo renamed on its forge keeps its binding and shape ledger
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 1m13s
CI & Build / integration (push) Successful in 1m15s
CI & Build / Python tests (push) Successful in 2m8s
CI & Build / Build & push image (push) Successful in 28s
A binding and every row of its project's code-shape ledger are keyed by the
repo's remote. A rename on the forge changes the remote, so binding the new name
started an empty ledger beside the old one and left every judgment behind.

bind_repo now takes moved_from, the old remote. Its binding and the project's
ledger rows move to the new key in one transaction, and the reply says how many
shapes moved. The move is refused when:
- the old name isn't bound to that project;
- the new name is already bound;
- the project already has a ledger under the new name.

Each refusal leaves both names exactly as they were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:53:57 -04:00
bvandeusenandClaude Opus 5.5 a07547447f rename(547): the repo is FabledGrimoire, and no stray "scribe" is left
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 28s
The forge repo is now bvandeusen/FabledGrimoire. The plugin marketplace default,
the install commands in the docs and plugin README, the CI requirements title and
the example repo URLs in comments and tests follow it. docs/development.md now
names the image the build actually publishes, fabledgrimoire.

Two test names still said "scribes_own"; they now say "grimoires_own".

What still says scribe does so on purpose:
- the backup reader's accepted archive names and prefix;
- the label every stored secret is sealed under;
- the notes on keeping an old install's database names;
- the old migrations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:29:00 -04:00
bvandeusenandClaude Opus 5.5 e45e4cd063 rename(547): the mark is a clasped grimoire
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 32s
favicon.svg is a violet book with gold corners and clasp and a four-pointed star
in a ring, replacing the indigo book with a spark. It is rendered to 192 and 512
PNG app icons and a 180 touch icon on the page surface.

manifest.json lists those icons instead of a favicon.ico that never existed. It
describes the product as the README does, and takes its colours from the design
system's page surface. index.html's theme-color follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:27:40 -04:00
bvandeusenandClaude Opus 5.5 101ab7bc8d fix(design): the app serves its own fonts and names no other host
theme.css loaded Fraunces, Inter and JetBrains Mono from Google Fonts. That sent
every visitor's address to Google on every page load, and an instance with no
internet drew in fallback type. All three families are now vendored under
frontend/src/assets/fonts with every subset Google publishes for them, plus their
OFL licences. The Content-Security-Policy no longer lists fonts.googleapis.com or
fonts.gstatic.com.

A new guard test fails if any stylesheet, page or component links a remote asset
or preconnects to one, or if the CSP names another host.

The theme.css header now describes the sheet as it is instead of how it used to
read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:27:40 -04:00
bvandeusenandClaude Opus 5.5 f1d26f01e2 rename(547): three hook-shell tests call the renamed functions
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 32s
The step-3 sweep matched `scribe` only after a non-letter, and in these
three tests the shell call sits right after a `\n` escape in a Python
string, so the character before it was the letter n. They still called
scribe_recent_context, scribe_skip_prompt and scribe_checkpoint_allowed;
they now call the grimoire_ names. No other escape-adjacent occurrence
remains in the tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:01:11 -04:00
bvandeusenandClaude Opus 5.5 c5eeec471f rename(547): the plugin is grimoire — manifest, MCP key, hooks, env, skills
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Failing after 1m26s
CI & Build / Build & push image (push) Skipped
Step 3 of the Scribe -> Grimoire rename.

- Plugin `grimoire` in marketplace `grimoire-plugin`, MCP server key
  `grimoire`, so tools reach a session as mcp__plugin_grimoire_grimoire__*.
- hooks/scribe_* -> hooks/grimoire_*, their shell functions and the TMPDIR
  ledgers with them; SCRIBE_URL / SCRIBE_TOKEN / SCRIBE_HOOK_DIR /
  SCRIBE_LEDGER_DIRS -> GRIMOIRE_*; the project marker is `.grimoire`.
- skills/using-scribe -> using-grimoire, /grimoire:sync, stored processes
  arrive as grimoire-proc-<slug>; moment_actions names them the same way.
- Migration 0124 rewrites stored skill names (rule_moments,
  rule_moment_judgments, moment_mappings) from scribe:/scribe-proc- to
  grimoire:/grimoire-proc-, so a rule mounted on a skill keeps firing.
- Settings' install commands and MCP-only defaults, the docs and the
  tests follow.
- scripts/check_plugin.py drops the smoke event for a report-check hook
  that has no file in plugin/hooks.

Kept on purpose: the secret-sealing label, and the names that let backups
written as Scribe restore and be counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:52:55 -04:00
bvandeusenandClaude Opus 5.5 0f5ef0be63 rename(547): the server is grimoire — package, image, DB defaults, backups
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
Step 2 of the Scribe -> Grimoire rename.

- src/scribe -> src/grimoire, and every import, patch target, module path,
  hypercorn entry point and `python -m grimoire.restore` with it; pyproject,
  uv.lock and the frontend package are named grimoire.
- Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service
  runs as grimoire / grimoire_test.
- Compose files default the DB user, database and (prod) volume to grimoire,
  each overridable from .env, so an install created as Scribe keeps its data
  by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME.
- Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar.
  Archives named scribe-* with the scribe-backup-tar manifest still restore
  and are still counted at the destination (folder and S3), with tests.
- Exports, the MCP auth realm, the ASGI scope key and the persistent logger
  carry the new name.

Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1",
because every stored secret was derived under it.

Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is
step 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:45 -04:00
bvandeusenandClaude Opus 5.5 e99f1169f7 rename(547): what people read says Grimoire
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 36s
Step 1 of the Scribe -> Grimoire rename: every capitalised "Scribe" in prose
becomes "Grimoire" ("Fabled Scribe" -> "Fabled Grimoire") across the UI,
the PWA manifest and page title, the MCP instructions and tool docstrings,
hook and skill text, docs and README, with the tests that assert on that
prose moving with it. Old migrations keep their wording.

Identifiers are untouched here: the package, plugin name, MCP server key,
hook file names, env vars, the .scribe marker and FabledScribe repo/image
names move in steps 2 and 3, along with prose that quotes them.

The sweep matched "Scribe" only as a whole word, so describe/subscribe and
friends are untouched. The longer name took the MCP instructions one
character over their 1,600 budget; two phrases are shortened to fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:46:37 -04:00
bvandeusenandClaude Opus 5.5 06be537a18 fix(ci): the image build stops pruning the runner's shared Docker daemon
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 13s
The build job ran `docker system prune -af` on the runner's shared Docker
daemon. That deletes every image no container is using at that moment,
including images other repos' jobs had just pulled and were about to start.
Jobs with a service container wait ~14s for it to become healthy between
pull and create, and fail with "No such image" when a prune lands inside
that wait (CI-runner #16). The runner host prunes nightly instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:20:33 -04:00
bvandeusenandClaude Opus 5.5 54206afa76 fix(6229): remove the Profile settings page — nothing read it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 43s
The Profile tab said an assistant used it to personalise chat and a daily
journal. Neither exists, and nothing read the profile: the page saved to
user_profiles and the backup carried it. Removed the tab, the client calls,
/api/profile, the service and the model, and migration 0123 drops the table
(downgrade recreates it empty).

Backup format v27 stops writing user_profiles; an older archive carrying the
section still restores, and the file-restore integration test now proves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:14:40 -04:00
bvandeusenandClaude Opus 5.5 067aa58fbc docs(6210): acceptance sweep — src, scripts and frontend leftovers described in the present tense
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 34s
The acceptance greps found what the per-area passes missed:

- src/scribe: "Phase 7/8" removal notes in projects, settings and
  user_profile; a paragraph promising an auto-summary side effect that no
  longer exists; user_profile's claim that "upcoming MCP profile tools" read
  the profile, which nothing does; an instance count and instance rule
  titles in plugin_context; "MILESTONE 323 DECIDED" in rulebooks.
- scripts/: check_plugin's header and inline comments were a history of
  the incidents behind each guard, with issue numbers in two failure
  messages; check_dangling_styles named this app's classes as its examples;
  check_design_tokens, measure_duplication and mint_plugin_version cited
  issues and checklists.
- frontend/src: forty-odd comments with four-digit issue numbers that the
  earlier frontend scan filtered out as hex colours, plus "used to" and
  "Phase N" notes.

Kept: illustrative ids (`#4181` as a bare-id example, `PR #198`), migration
Create Date headers and one migration's dated data floor, and the plugin
skills' worked examples of how to cite a record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:54:38 -04:00
bvandeusenandClaude Opus 5.5 4c150cbc2f docs(6209): test prose in the present tense — "used to", "previously", "before this" restated as the failure shape
Twenty-eight test files described the code they guard by what it did
before a fix. Each is now the property and the failure it prevents, in the
present tense: the backfill snapshot, the per-dial calibration query, the
rule scope, the lesson trigger filter, the subshell global, the trash owner
scope, the version readout fallback, and others. A docstring line that
narrated the backup suite's own history is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:46:57 -04:00
bvandeusenandClaude Opus 5.5 914f31359c docs(6209): the remaining test files — lessons, reply moment and shape, retrieval specs and scopes, rule doors and judgments, services, snippets, search scope; five tests renamed to their property
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 15s
Docstrings, section headers and assertion messages drop the record number,
milestone or step that introduced the behaviour, and incident accounts are
restated as the present-tense failure shape. A dead pointer to a design doc
that is not in the repo is removed.

Renamed to the property they check:
  test_the_old_shape_would_be_caught -> test_a_rank_then_scope_query_is_caught
  test_a_ledger_repeat_no_longer_produces_that_case_at_all -> test_a_ledger_repeat_is_counted_not_withheld
  test_the_header_no_longer_promises_once_per_session -> test_the_header_explains_the_seen_marker
  test_the_portal_case_no_longer_stamps_one_canon_onto_a_whole_file -> test_one_canon_is_not_stamped_onto_a_whole_file
  test_the_repos_own_prose_no_longer_defines_with -> test_the_repos_own_prose_does_not_define_with
  test_the_original_regression_create_note_with_content -> test_the_real_server_refuses_content_on_create_note

Fixture and example ids stay: "note 5" in a supersession chain, "rule 156"
in an illustrative question, `#2275` as the hex-versus-citation case,
`#3244` as the example of a bare number, fixed dates in date-filter tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:44:58 -04:00
bvandeusenandClaude Opus 5.5 79de8938b9 docs(6209): forty test files — design cascade and stylesheet, embeddings, family canon, forge, backup round trips, inception, placement, platforms, moments, rule move and verification, search scope, facets
Module and test docstrings state what is pinned without the record number
or step that introduced it. Incident accounts become the failure shape in
the present tense: the pipefail head, the code_shapes import, the export
order, the facet dialects, the cross-kind repeat, the write-once kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:40:25 -04:00
bvandeusenandClaude Opus 5.5 63e97a6f0f docs(6209): seventeen more test files — lesson shape and round trip, rule scope/moments/kind/surfacing, drift, deadlock race, backup, inception, backfill, claims, supersession, ledgers, family shapes
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 12s
Docstrings and comments state the property and its reason in the present
tense. The session-context ledger test no longer describes the decision it
"used to pin"; the supersession tests state demote-not-filter without the
quote; one-row measurements become the general shape with their figures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:37:15 -04:00
bvandeusenandClaude Opus 5.5 ba0b56018e docs(6209): calibration stamp, after-write hook, usage seam, work log, duplicates, skill structure, tuning and rulebook routes, kind surface, prompt filter, moments, strict args, auth, lesson write path
Fourteen more test files: docstrings state the failure each guard defends
as a present-tense shape instead of the incident that found it, and issue,
milestone and rule numbers leave comments and assertion messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:35:44 -04:00
bvandeusenandClaude Opus 5.5 d0d278dcce docs(6209): snippets, rule embeddings, notes, ledger ageing and references, routes, reporting, handshake, lesson kind, request deadlines, contract-around-change, family models
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m14s
CI & Build / Python tests (push) Successful in 1m59s
CI & Build / Build & push image (push) Successful in 15s
Twelve more test files drop issue, milestone, rule, note, lesson and
snippet numbers and the dated or one-project figures that recorded how
each property was found; the docstrings state the property and why it
holds. The contract-around-the-change guard keeps the dict-key example as
the failure a signature-watcher misses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:34:24 -04:00
bvandeusenandClaude Opus 5.5 ead4a7e2ad docs(6209): preference door, systems and notes tools, lesson surfacing, rule versions, backup round trip, JSON reader, charter search, provenance, location filter
Ten more test files: issue, milestone, rule, decision and snippet numbers
come out of docstrings, comments and messages; the preference door states
what a preference is instead of quoting the framing; the JSON reader says
why jq is not assumed instead of what every hook used to do; the backup
round trip's column warning no longer claims the import side is unguarded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:33:08 -04:00
bvandeusenandClaude Opus 5.5 fa41e006a7 docs(6209): lesson links, design roles, version readout and endpoint, timestamp filters, settings defaults, opened ledger, surfaces
Eight more test files state their properties without issue, milestone and
rule numbers or dated regressions. The version tests describe what folding
the channel into the version breaks instead of the date it did; the surface
registry and settings-defaults tests state that floors move rather than
quoting the decision that made them move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:32:13 -04:00
bvandeusenandClaude Opus 5.5 b3e6a27dc1 docs(6209): forge, dedup, tuning, registry, reply shapes and discipline, checkpoint, lesson kind tests
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 25s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m24s
CI & Build / Python tests (push) Successful in 2m13s
CI & Build / Build & push image (push) Successful in 13s
Eight more test files state their properties without issue, milestone,
rule and decision numbers. The tuning and suspension notes describe the
symptom a shipped default or a late ledger produces instead of one
install's dated readings; the reply-discipline and checkpoint docstrings
state the failure they prevent instead of the session that measured it.
test_empty_selector_is_the_rule_115_baseline is renamed for what it
checks: the no-forge baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:30:54 -04:00
bvandeusenandClaude Opus 5.5 0e658bf275 docs(6209): task document, query scrubbing, note pipeline, lesson door, live body, ledger clear, rule usage
Seven more test files: docstrings and comments state the property and its
reason, the SQL-twin note states why `\m` and not `\b` without the install
it broke, and fixture provenance says "real near-miss queries" rather than
naming the spike that collected them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:29:49 -04:00
bvandeusenandClaude Opus 5.5 17e1931c0b docs(6209): rulebooks, pull telemetry, lesson usage, duplicate arm, cited status, trigger shape
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 13s
Six more test files state their pinned properties without the issue and
milestone history: the pull-telemetry guard explains enumerating kinds
instead of listing two getter incidents, cited-record status explains why
a hint's id needs its status instead of recounting one misreported step,
and the trigger-shape guard keeps its reasoning about why prose heuristics
fail while dropping the dated corpus audit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:28:35 -04:00
bvandeusenandClaude Opus 5.5 ff08500128 docs(6209): wide net, startup, stamps, slippage, miss route and parity tests
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m10s
CI & Build / Build & push image (push) Canceled after 0s
CI & Build / Python tests (push) Canceled after 1m23s
Eight more test files state the property they pin as a present-tense
fact. Incident narratives (a dated storage stall, a week of zero tuning
events, a two-project poisoned ledger, a twenty-site owner audit) become
the failure shape each test defends; one-install figures that name a rule
become a general worked example with the calibration numbers kept. The
family-grouping regression guard is renamed for what it checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:27:17 -04:00
bvandeusenandClaude Opus 5.5 d41e72d26c docs(6209): twelve more test files say which behaviour they pin
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 12s
shape ledger, MCP projects/search/rulebooks tools, backup, chunking,
plugin context, derived mirror, rule band, shape form gate, plugin
version mint and note usage tests drop issue, milestone and rule numbers,
dates and one-install figures from docstrings, comments and messages.
Three shape-form-gate test names that told the discovery story now name
the property: a registry module's helpers are not service functions, the
divergence gate silences only a family contradiction, and callable helpers
beside a callable canon still prompt. The rulebook tool-count comment that
logged each milestone's additions is replaced by the reason it is a count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:24:50 -04:00
bvandeusenandClaude Opus 5.5 91c96a8b8a docs(6209): coverage, guidance-ownership, shape, warnings tests and shared fixtures
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Successful in 1m56s
CI & Build / Build & push image (push) Successful in 13s
test_pattern_coverage, test_guidance_ownership, test_integration_shape_classify,
test_instruction_surfaces_agree, test_retrieval_warnings, helpers.py and
conftest.py state the property each test or fixture holds. The issue and
milestone numbers, consolidation dates and one-install measurements that
recorded how each was found are dropped; fixture dates and ids stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:21:52 -04:00
bvandeusenandClaude Opus 5.5 8518afb54d docs(6209): rule usage, write path and telemetry tests state the behaviour they pin
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m18s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 17s
Docstrings, comments and assertion messages in the three most-cited test
files now say which property each test holds and why, without the issue,
milestone or rule numbers, dates and one-install measurements that recorded
how each property was discovered. Fixture ids and calibration scores stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:18:47 -04:00
bvandeusenandClaude Opus 5.5 bdd803537b docs(6208): the remaining frontend comments drop milestone, step and rule citations
CI & Build / Python lint (push) Successful in 9s
CI & Build / Plugin hooks (push) Successful in 22s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m55s
CI & Build / Python tests (push) Successful in 2m20s
CI & Build / Build & push image (push) Successful in 1m7s
Components, stores, api clients, router, types and shared CSS now state what
each piece is and why, without the milestone that introduced it. diff.ts
names its consumers instead of the copy history; stores/notes and tasks state
their current scope instead of the cleanup that set it; theme.css states the
token practice instead of the sweep that established it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:14:03 -04:00
bvandeusenandClaude Opus 5.5 699a4cd986 docs(6208): ProjectView, KnowledgeView, SnippetListView and components.css comments drop citations and stories
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m10s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 52s
components.css says why shared recipes live there without the five-copy
.btn-primary count; KnowledgeView's editor-routing and chip comments state
the present reason instead of the bug that once routed snippets wrong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:10:45 -04:00
bvandeusenandClaude Opus 5.5 637ec66b79 docs(6208): SettingsView comments state the reason, not the instance that taught it
Record and milestone citations go; the ACT-arm bar split and the per-arm
budgets state why they exist without the measurements that motivated them.
A comment about a removed think_enabled setting, pointing at a file that no
longer exists, is deleted, as is the story of a removed design-system picker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:09:53 -04:00
bvandeusenandClaude Opus 5.5 79655fd19f docs(6207): the remaining migrations state their rationale in terms of the data
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 59s
CI & Build / integration (push) Successful in 1m32s
CI & Build / Python tests (push) Successful in 2m33s
CI & Build / Build & push image (push) Successful in 26s
Record, milestone, phase and plan citations go from every migration header;
the "end before start" fix (0043) and the suppressed-count column (0095)
state the condition they prevent rather than the incident. 0053 and 0055
pointed at design specs under docs/superpowers that are not in the repo;
those pointers are removed.

Kept: alembic's generated "Create Date:" headers (template metadata, not
rationale), 0121's ARM_BORN justification beside the constant, and 0051's
illustrative "update task 42" format example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:49:32 -04:00
bvandeusenandClaude Opus 5.5 79059ca1e7 docs(6207): migrations 0079, 0082, 0087, 0104, 0111, 0112, 0116-0118 drop milestone and record citations
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:47:43 -04:00
bvandeusenandClaude Opus 5.5 8c9bb000fa docs(6207): migrations 0101, 0105, 0106, 0108, 0120, 0121 drop citations and the operator quote
0121 keeps the ARM_BORN date beside the constant it justifies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:47:14 -04:00
bvandeusenandClaude Opus 5.5 9e1da4af3d docs(6207): migrations 0088 and 0090-0094 state their rationale in terms of the data
Milestone/step labels, record ids, the rescoped-rule date and the 296-call
measurement go; each header states what the change protects. "rule 36"
citations become the DROP + ADD practice they named.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:46:36 -04:00
bvandeusenandClaude Opus 5.5 40f603ba28 docs(6206): retrieval_tuning model docstring drops the decision quote
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:44:50 -04:00
bvandeusenandClaude Opus 5.5 f6734291c9 docs(6206): the remaining models, routes, mcp/tools/repos and config comments drop citations
A shipped tool docstring (mcp/tools/repos bind_repo) cited this instance's
"rule 1: dev is home"; it now says what a dev-branch project should do.
routes/knowledge, routes/tasks, routes/systems and models/retrieval_log state
the property each guards rather than the bug that once broke it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:44:31 -04:00
bvandeusenandClaude Opus 5.5 6ec78b530e docs(6206): webhooks, search, rulebooks routes and retrieval_tuning, note, models/__init__ drop citations
routes/search states the derived-facets property rather than the silent
whole-corpus bug that motivated it; routes/rulebooks gives the preference
restore exception its reasoning without the milestone numbers it cited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:43:32 -04:00
bvandeusenandClaude Opus 5.5 0c88e1b0b0 docs(6206): settings, projects, api routes and three models drop citations; four capitalised services stragglers
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 24s
- api.py's build-version docstring states why a channel name is not a build
  id, without the dated incident.
- rule_version states why rules need a history, without the rule that was
  rescoped and the date.
- Capitalised "Rule N" / "Milestone N" citations the earlier lowercase grep
  missed, in reply_shapes, supersession, services/family and models/family.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:42:41 -04:00
bvandeusenandClaude Opus 5.5 78e1bdbd93 docs(6206): retrieval, snippets, notes routes and rule_usage model drop citations
Corrected while reading: routes/notes.py said lessons and snippets pass the
owner to set_record_systems, "a real inconsistency". Every door passes the
caller; the comment now says so.

rule_usage's model docstring states what an outcome measures and why the
row cannot share the note table, without the measurement and record ids
that motivated it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:41:13 -04:00
bvandeusenandClaude Opus 5.5 6a9f02990c docs(6206): embedding model docstrings state the sibling-table reason in place
The rule-embedding docstring carries the own-table test itself instead of
pointing at a record; the later siblings refer back to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:39:35 -04:00
bvandeusenandClaude Opus 5.5 2a5276eb0e docs(6206): routes/lessons comments drop citations and the corpus measurement
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:39:00 -04:00
bvandeusenandClaude Opus 5.5 0c237172e4 docs(6206): app.py startup comments state the hazard, not the outage
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 1m11s
CI & Build / integration (push) Successful in 1m35s
CI & Build / Python tests (push) Successful in 2m23s
CI & Build / Build & push image (push) Successful in 23s
The deferred-backfill and inline-release-defaults comments describe the
lifespan-overrun hazard they guard without the dated incident; backfill
passes say what they fill rather than which milestone added the vectors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:38:30 -04:00
bvandeusenandClaude Opus 5.5 cad74df190 docs(6206): rulebook model comments state the design, not the history of the rulebook
The relation kinds say what each prevents without the rule-46 split story;
the removed tier, subscriptions and suppressions are stated as what the
model does not have, with the migration that dropped them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:38:01 -04:00
bvandeusenandClaude Opus 5.5 a01f5492bc docs(6206): code_shape model comments drop record and milestone citations
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:37:22 -04:00
bvandeusenandClaude Opus 5.5 edf725687a docs(6206): routes/plugin comments drop record and milestone citations
The pre-tool rules docstring says why action rules need their own surface
instead of narrating that they once lived in the preload; the write-path
checkpoint note states the reason the hook never blocks rather than whose
decision it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:36:52 -04:00
bvandeusenandClaude Opus 5.5 87e8f8f777 docs(6205): last id-less stories in services — a ported-from project name and two "this milestone" references
backup_s3 says what its signing is checked against instead of which of the
operator's projects it was ported from; rulebooks and shape_check state what
the code is for rather than which milestone it belonged to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:35:04 -04:00
bvandeusenandClaude Opus 5.5 fa4f88747e docs(6205): the remaining services files state the reason, not the instance that taught it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m46s
CI & Build / Python tests (push) Successful in 2m36s
CI & Build / Build & push image (push) Successful in 31s
The 1-2 hit tail: access, background, backup, backup_job, canonical_systems,
design_stylesheet, forge_connections, moment_actions, notes, notifications,
placement, planning, platforms, projects, record_batch, recurrence,
report_check, retrieval_review, rule_scope, rule_versions, scheduler,
shape_check, supersession, system_rulings, system_usage, text, trash,
verification, version_pinning.

Also corrected while reading:
- backup.py said restore step 16 fills the System map; step 15 does.
- version_pinning.py pointed at a design doc that is not in the repo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:34:24 -04:00
bvandeusenandClaude Opus 5.5 172e784cb2 docs(6205): rule_moment_judgments, retrieval_migration, repo_bindings, moments, logging, design_systems comments drop citations
retrieval_migration states what it lets a tuned floor do without quoting
the operator, and its order-of-operations refusal is described as enforced
rather than as history. logging states why raw date filters fail rather
than citing the two issues that found it. The rest drop milestone, step and
record citations; step references now name the code that does the job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:32:07 -04:00
bvandeusenandClaude Opus 5.5 cc5401b2d3 docs(6205): task_logs, task_claims, reply_shapes, design_starter_roles, db_maintenance_scheduler comments drop citations and incident stories
- db_maintenance_scheduler states the storage-stall hazard the startup
  timeout guards, without the dated outage.
- design_starter_roles states why a missing role produces literals, without
  the call-site count, the operator's call date or a project's name.
- The rest drop milestone/step labels and record citations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:31:14 -04:00
bvandeusenandClaude Opus 5.5 32ebc2b1d4 docs(6205): retrieval_surfaces and family_adoption comments drop citations and the decision quote
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m25s
CI & Build / Python tests (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 20s
retrieval_surfaces' header states why the arms read their numbers from one
table and why a floor is moved by reading the refused record, without the
quoted decision, the retired arm or the declined-rule instance.
family_adoption drops milestone/step labels from its section headers and
states the no-ids reason instead of naming the trap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:29:52 -04:00
bvandeusenandClaude Opus 5.5 dd4bc234e9 docs(6205): inception and family comments drop record, milestone and date citations
inception states the legacy stamp and the retired rulebook keys as facts
about stored records; family keeps the repeat-trigger calibration numbers
without the date and record they were measured on, and drops the instance
that taught the per-kind comparison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:29:03 -04:00
bvandeusenandClaude Opus 5.5 5f96545ae1 docs(6205): record_refs and moment_delivery comments drop record and milestone citations
record_refs states why a guessed id is refused without quoting the
operator; moment_delivery's docstrings name what each door does without the
milestone and step that built it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:28:23 -04:00
bvandeusenandClaude Opus 5.5 5382e0a7da docs(6205): systems comments state the reason, not the instance that taught it
The name gate, catalog and starter-set docstrings drop milestone and record
citations and the history of the tuple constant; each states why it is
shaped this way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:27:44 -04:00
bvandeusenandClaude Opus 5.5 6dab239eb2 docs(6205): note_usage comments state the reason, not the instance that taught it
The silent-outage story (#2663), the ranked/ambient split (#2477), the
transfer evidence (#3735) and the attach_usage consolidation (#4230) are
stated as the property each guards, without the incident.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:27:19 -04:00
bvandeusenandClaude Opus 5.5 757d016a0c docs(6205): milestones comments state the reason, not the instance that taught it
The pool-exhaustion, handshake-size and stepless-roadmap comments state the
condition the code guards instead of the measured incident; citations go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:26:50 -04:00
bvandeusenandClaude Opus 5.5 29fd64f67e docs(6205): forge comments state the reason, not the instance that taught it
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m22s
CI & Build / Python tests (push) Successful in 2m11s
CI & Build / Build & push image (push) Successful in 25s
The module header drops the milestone/step map and record citations; the
per-user optionality, the keyring model and the empty-selector baseline are
stated as present-tense facts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:26:18 -04:00
bvandeusenandClaude Opus 5.5 16367b02d2 docs(6205): retrieval_registry comments state the reason, not the instance that taught it
Record and milestone citations go. The logs_unconditionally docstring, the
place arm's comment and the fan-out key note state the condition they guard
rather than the bug that once hit it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:25:48 -04:00
bvandeusenandClaude Opus 5.5 502ecf6743 docs(6205): retrieval_tuning comments state the reason, not the instance that taught it
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Build & push image (push) Canceled after 0s
CI & Build / Python tests (push) Canceled after 1m3s
CI & Build / integration (push) Canceled after 1m1s
The module docstring states why the model moves dials and why a reason is
required, without the decision quote or the declined-rule story. Bare
record citations go; the release-default and ledger-gap docstrings describe
the condition they guard instead of the measurement that found it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:25:15 -04:00
bvandeusenandClaude Opus 5.5 5605b863b2 docs(6205): lesson_rules comments state the reason, not the instance that taught it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m10s
CI & Build / Build & push image (push) Canceled after 0s
CI & Build / Python tests (push) Canceled after 1m48s
Issue, milestone, snippet and rule-number citations are removed; the scope
docstring states the leak it prevents without naming the milestone that
closed it for the direct arms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:23:11 -04:00
bvandeusenandClaude Opus 5.5 21a409eac1 docs(6205): knowledge comments state the reason, not the instance that taught it
The ACL header states the share behaviour without its date and decision ids;
the facet table, door map, floor constant and facet-count comments state why
they are single sources instead of retelling how copies drifted; this
instance's counts (legacy plan-tasks, issue share) are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:22:42 -04:00
bvandeusenandClaude Opus 5.5 d1b442c572 docs(6205): lessons comments state the reason, not the instance that taught it
The module docstring keeps why the kind exists, where its trigger lives, what
it inherits and why it needs no migration, without milestone, decision and
note ids or this instance's snippet counts. The body-strip docstring states
why taking the note matters instead of retelling the defect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:21:57 -04:00
bvandeusenandClaude Opus 5.5 7e9d33d9d9 docs(6205): rule_usage comments state the reason, not the instance that taught it
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 1m6s
CI & Build / integration (push) Successful in 1m24s
CI & Build / Python tests (push) Successful in 2m11s
CI & Build / Build & push image (push) Successful in 26s
The module docstring keeps why rules need their own usage stream and why the
ranked list is the one that is named, and drops the dated story of the
bulk surfaces starting to emit, measured decline rates and the corrected
earlier wording. Outcome docstrings describe the four states without
milestone references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:19:50 -04:00
bvandeusenandClaude Opus 5.5 5d48e23285 docs(6205): notes comments state the reason, not the instance that taught it
Issue, decision, milestone and operator-decision citations are removed;
"previously each route…" and "list_tasks once returned…" stories become the
present-tense reasons they taught. The note about the removed delete_note
now says plainly that there is deliberately none, and why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:18:30 -04:00
bvandeusenandClaude Opus 5.5 cdb49b2f9f docs(6205): dedup comments state the reason, not the instance that taught it
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m25s
CI & Build / Python tests (push) Successful in 2m13s
CI & Build / Build & push image (push) Successful in 24s
Issue, milestone and rule citations and dated measurement headers are
removed; calibration figures stay. Corrected the lesson-gate comment, which
said lessons are not in _REPORT_KINDS and so have no duplicate-report
backstop. They are, and the comment now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:17:09 -04:00
bvandeusenandClaude Opus 5.5 0a310f326f docs(6205): rulebooks comments state the reason, not the instance that taught it
Milestone, issue, note and rule-number citations are removed. The
co_surfaces docstring states why paired rules need the edge instead of
retelling the instance that taught it; the list-rules and applicable-rules
docstrings drop the retired subscription history. The preference-restore
error string no longer cites a milestone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:15:41 -04:00
bvandeusenandClaude Opus 5.5 57773c8b55 docs(6205): snippets comments state the reason, not the instance that taught it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 22s
Issue, decision, milestone and rule citations are removed; the snippet-mirror
docstring drops the paragraph about its own earlier wording, and the
pointer-model and freshness comments state the design rather than the plan
step that introduced it. Access notes point at services/access.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:14:09 -04:00
bvandeusenandClaude Opus 5.5 5598b307fa docs(6205): coverage comments state the reason, not the instance that taught it
Issue, note and milestone citations are removed along with first-deploy
counts and the measured phantom-definition story; "button B" jargon becomes
the divergence it names. Each block keeps its present-tense reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:12:53 -04:00
bvandeusenandClaude Opus 5.5 c44372c8cd docs(6205): retrieval_telemetry comments state the reason, not the instance that taught it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 25s
The recurring "#2663 shape" and similar issue citations become the property
they stand for (an outage that reads as a quiet window, a statistic that
cannot vary). Dated observations, milestone tags and rule numbers are
removed; the near-miss and pull-through reasoning stays in present tense.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:11:10 -04:00
bvandeusenandClaude Opus 5.5 be9a659e9b docs(6205): backup comments state the reason, not the instance that taught it
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m11s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 22s
The format-version log keeps what each version adds, because restore depends
on what an older archive can carry; dates, milestone and issue ids, and the
stories of what a missing column once cost are removed. Compatibility
comments now name the archive version instead of a milestone. Two leftover
history-phrased comments in embeddings and shape_ledger are restated as
present-tense reasons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:08:12 -04:00
bvandeusenandClaude Opus 5.5 0c24fa392c docs(6205): embeddings comments state the reason, not the instance that taught it
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 1m10s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 23s
Record ids, milestone tags, dated measurements and "used to" stories are
removed; the chunker version list now says what each stamped version
contains. Corrected two comments that said a failed search leaves
report["searched"] absent (it is stamped False first), and pointed the
rule-search docstring at _RULEHINT_BAND in retrieval_pipeline instead of a
tier note in plugin_context that no longer exists. plugin_context's own
dangling "note on tiers" pointer is reworded to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:04:59 -04:00
bvandeusenandClaude Opus 5.5 4ebd8e88fb docs(6205): retrieval_pipeline comments state the reason, not the instance that taught it
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 23s
Record ids, milestone and step numbers, and the stories of copies drifting
apart are removed; each invariant and slot is justified in present tense.
The rule-band probe table stays as calibration. Section headers drop their
milestone tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:01:12 -04:00
bvandeusenandClaude Opus 5.5 a41690a508 docs(6205): shape_ledger comments state the reason, not the instance that taught it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 24s
Record ids, snippet numbers, dated measurements and the proposer's version
history are removed; each block now states the design reason in the present
tense. Calibration figures that justify a floor stay, without the instance
they were measured on. One error string drops a note id. The density
threshold comment moves above the constants it describes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:57:23 -04:00
bvandeusenandClaude Opus 5.5 33142db3ed docs(6205): plugin_context comments state the reason, not the instance that taught it
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m16s
CI & Build / Python tests (push) Successful in 1m52s
CI & Build / Build & push image (push) Successful in 23s
Record ids, milestone numbers, dated telemetry readings and rule numbers
leave the retrieval-arm comments and docstrings. Each bar keeps its
present-tense reason and the probe-score calibrations, which describe the
model on any install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:52:55 -04:00
bvandeusenandClaude Opus 5.5 17777e9bb1 docs(6204): shipped instruction text describes the product, not this instance's records
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m10s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 39s
MCP tool docstrings and comments, the server's orientation notes, the plugin
skills, every hook script and the plugin README/PACKAGING drop record ids,
milestone numbers, dates and incident stories, and state each non-obvious why
as a present-tense fact. History stays in the task logs.

Corrections found on the way:
- plugin README/PACKAGING said every hook is a GET; moment, rule-opened and
  reply-rules POST, so the plugin's key needs write scope. Both now say so,
  name Settings -> MCP Access, and list every wired hook and endpoint.
- the contract-change hint printed "(lesson #4207)", this instance's record,
  to every install. It no longer cites a record.
- tasks.py claimed there is no delete_task; there is.
- rulebooks.py cited delete_event and a design spec that no longer exist.
- scribe_after_write.sh carried an always-empty etag_q; removed.
- test_write_path_trigger pinned "| tac | scribe_defs | head -1", text that
  existed only in a comment about the old code; it now pins the real tail -1
  pipeline.
- docs/development.md: CI runs on Gitea Actions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:45:09 -04:00
bvandeusenandClaude Opus 5.5 fc8f554cee fix(6219): g t opens Knowledge, and the shortcuts overlay lists every g shortcut
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 36s
g t pushed "/", which now redirects to the dashboard, so it did what g h
does. It pushes /knowledge. The overlay gains g r (Rules) and g g (Graph),
calls g h "Dashboard", and docs/features.md lists g t.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:29:45 -04:00
bvandeusenandClaude Opus 5.5 47d40bffd8 docs: architecture, configuration, development and features describe Scribe as it is today
Milestone 544, step 6217 (follow-up to #1778, which rewrote features.md and
api-reference.md in July but left architecture.md and configuration.md alone).

- architecture.md, configuration.md, development.md: rewritten against the
  code. They still described Ollama, chat, SSE generation, voice, project
  summaries, image caching, Docker secrets that the compose files don't use,
  a raw-SQL migration convention the migrations no longer follow, a 27-entry
  migration list, CalVer YY.MM.DD.N tags and the removed infra/ files.
- features.md: adds what shipped since July (spikes, claims, moments, rule
  versions and verification, lessons, reply shapes, snippets and the shape
  ledger, forge integration, family canon, design systems, retrieval tuning)
  and corrects the Settings tabs and keyboard shortcuts (g g is the graph).
- api-reference.md: adds the endpoints for snippets, lessons, design
  systems, family canon, platforms, shared areas, retrieval, rule versions,
  relations and verification, forge connections, webhooks, scheduled
  backups and the newer plugin hooks.
- api-keys-and-mcp.md: Settings tab is "MCP Access"; a plugin install
  section; the tool-family table matches the tools that exist.
- design-system.md: the dated "decisions in progress" changelog (April
  PR log, chat/journal/calendar surfaces) becomes a short present-tense
  section; the stored design system is named as the authority.
- sso-oauth.md: the app is Scribe, not "Fabled Assistant".
- README: feature summary and doc table brought current.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:23:56 -04:00
bvandeusenandClaude Opus 5.5 82e49176b1 docs: remove dated design plans and one install's runner config
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 39s
At the operator's request (milestone 544, step 6203):
- docs/2026-03-23-fable-mcp-{design,plan}.md, 2026-03-29-voice-s2s-design.md
  and 2026-04-10-agentic-briefing-design.md were plans for work that has
  since shipped or been retired (voice and briefings left in the pivot).
  Plans live in Scribe milestones.
- infra/act-runner-config.yml and runner-compose.yml described one
  install's CI runner (an /nfs path, the py3.12-node22 label, a
  RUNNER_SETUP.md that no longer exists). The runner is the CI-Runner
  repo's concern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:15:20 -04:00
bvandeusenandClaude Opus 5.5 cba2c24612 feat(544): shipped compose files run on the orchestrator's defaults; the app waits for its database itself
Brings the three compose files in line with rule 198 (milestone 544,
step 6203, at the operator's request):
- no healthcheck on the app or the database, so a slow pg_isready on
  stalled storage can no longer get a working database killed;
- no deploy.restart_policy in the Swarm file, so Swarm's default
  (condition: any) restarts clean exits too, instead of on-failure with
  max_attempts leaving the app down;
- depends_on is plain start order; restart: unless-stopped stays on the
  plain-Compose files (Swarm ignores it), and the dev app gains it.

The orchestrator used to be what retried a container that started before
Postgres was ready. That is now the app's job: alembic/env.py calls
services/db_ready.wait_for_database before migrating. It retries refused
connections, unresolved hosts, connect timeouts and "the database system
is starting up" with 1-2-4-...-10s spacing for up to 300s, then exits
non-zero; a wrong password or missing database fails at once.
Per-request recovery is the engine's pool_pre_ping.

Also fixes the dev compose database image: postgres:16-alpine has no
vector extension, so migrations could not run against it. It now uses
pgvector/pgvector:pg17 like the other two files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:15:20 -04:00
bvandeusenandClaude Opus 5.5 9bb3b68cc4 docs: compose files, Dockerfile, CI workflow, README and ci-requirements describe the build, briefly
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 51s
Comment-only pilot for the comment standard (rule 32, milestone 544).
Issue and rule numbers, dates, run ids and incident stories come out;
the constraint each one guarded stays as a present-tense sentence.

History removed from the files, kept here:
- Dockerfile/CI: installing from uv.lock replaced pip/uv-pip installs that
  ignored the lock (#2194, the mcp 2.0.0 break on 2026-07-28); --locked
  then caught pgvector missing from the lock.
- CI: plugin paths were added after #2198/#2209 reached a live install;
  `integration` joined the build's needs after run 8212 published
  :latest with integration red (#4958); the `[[ ]]` tag step failed
  silently on dash (2a374d9); BUILD_VERSION carried the channel until
  2026-08-31 (#3298).
- jq was removed from the hooks in #4107.
- prod compose: the lenient DB healthcheck followed a runc setns crash
  loop; pgvector arrived with migration 0067 via a PG16->17 dump/restore.

Corrections to match the code: README names the Settings tab
"MCP Access"; ci-requirements says the Dockerfile builds on node:22
and lint covers scripts/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:38:42 -04:00
bvandeusenandClaude Opus 5.5 38d02d9c6f fix(523): the backup carries user_profiles - the Profile page travels with its user (#5879)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m56s
CI & Build / Build & push image (push) Successful in 27s
Settings -> Profile (name, role, expertise, response style, tone, interests,
working hours) is typed by its user and neither derived nor a credential, yet
_NOT_INCLUDED named it, so a restore dropped it without a word.

BACKUP_VERSION 26. Both exports carry it; the restore remaps only the user, and
a blank field stays blank rather than restoring as the page's default. The
column and import guards cover it, and the tar round-trip restores one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:55:52 -04:00
bvandeusenandClaude Opus 5.5 07bc51b890 feat(523): forge-connection tokens are stored sealed under SECRET_KEY, like the backup S3 secret
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 36s
The column becomes token_sealed (migration 0122 seals existing rows with a
frozen copy of the v1 key), every reader opens it through connection_token,
a row this instance cannot open is skipped with a warning, and Settings asks
for that token again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:08:11 -04:00
bvandeusenandClaude Opus 5.5 3008d29f89 fix(523): restore CLI imports the engine from scribe.models, where it lives
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:42:54 -04:00
bvandeusenandClaude Opus 5.5 b45fc9efba feat(523): restore reads the backup tar; python -m scribe.restore brings a fresh instance back
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Failing after 1m8s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Skipped
The restore route takes the tar as a raw body beside the JSON download.
scribe/restore.py reads either format, refuses a database that already
has an account (a restore re-creates everything with new ids, so into a
live instance it would duplicate rather than replace), has no size
limit, and says to restart so search indexes rebuild. An integration
test restores a written tar through the real restore_full_backup and
checks every id is remapped. docs/backups.md explains the file, why it
is uncompressed, both destinations, Fabled Archives as the worked
example, and restoring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:40:24 -04:00
bvandeusenandClaude Opus 5.5 be59829dde feat(523): Settings -> Data -> Scheduled backups - the verdict first, then destination, schedule, Test, Back up now and recent runs
BackupScheduleSettings.vue leads with whether the data is backed up
(last good backup, or Overdue), then what is wrong (last failure in
words, an S3 secret this SECRET_KEY cannot open, incomplete config),
then the form: daily toggle and UTC hour, folder or S3 bucket, Test
destination with unsaved values, Back up now (polls while running,
re-armed in finally), and the last runs. The secret is write-only.
formatBytes moves to utils/bytes.ts for both views. Restore from
Backup now also takes the .tar, and says to use the CLI on a 413.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:40:24 -04:00
bvandeusenandClaude Opus 5.5 4248b450ea feat(523): the backup job - an hourly tick that runs at the chosen hour, Back up now, and the last 30 runs
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 23s
services/backup_job.py reads its config when it fires, so startup reads
nothing (#4181) and a changed hour needs no reschedule. A run is held to
a 60-minute deadline (a hung NFS mount would otherwise hold the lock and
skip every later backup), never runs twice at once, and keeps its outcome
- file, size, duration, or the reason in words - in the bounded
backup_runs setting, which no backup carries. summarise() gives the page
its verdict: overdue when on and no success for 26h.

Admin API: GET/PUT /api/admin/backups, POST /backups/check (tests unsaved
form values, 45s deadline), POST /backups/run (202, background).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:34:01 -04:00
bvandeusenandClaude Opus 5.5 ff4f2ee5c2 feat(523): backup destinations - a folder or an S3 bucket, with the S3 secret sealed and kept out of backups
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 36s
services/backup_s3.py is FabledSteward's SigV4 client ported (put + list,
never delete, so an append-only key is enough), with its tests against
AWS's published signatures. services/backup_destination.py holds the
admin config (backup_config JSON) and delivers a dated tar to a mounted
folder or a bucket, and checks either without sending a backup.
services/secrets.py seals the S3 secret with Fernet under a key derived
from SECRET_KEY; backup._SETTINGS_NOT_EXPORTED keeps it out of every
backup. cryptography becomes a direct dependency (already in the image
through pyjwt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:31:08 -04:00
bvandeusenandClaude Opus 5.5 1942a7a336 feat(523): the backup as an uncompressed tar of per-table JSON Lines, ordered so restic dedupes it
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 1m36s
export_full_backup orders every query by primary key: an UPDATE moves a
row to the end of the heap, so an unordered export shifted every byte
after an edited row. services/backup_file.py writes the payload as one
dated tar per run - manifest.json plus <section>.jsonl, sorted keys,
fixed member headers - atomically via a dot-prefixed .part file, and
reads it back into the dict restore_full_backup takes, refusing a
missing or short section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:30:16 -04:00
bvandeusenandClaude Opus 5.5 69dddb7551 feat(500): Settings shows the shipped reply shapes, what adjusts each, and how often each arrives
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 1m58s
CI & Build / Build & push image (push) Successful in 39s
Settings > General > Reply shapes. One menu row per shape (core, completion,
asks, plan): its title and when it arrives, how many of the operator's
preferences adjust it, and its deliveries over 30 days (in full / as a
reminder, "—" when the counts cannot be read). A verdict line leads. Opening a
row shows the shipped text, read-only because it is product, and the
preferences mounted on its moment; each opens in the rule editor, and "Adjust
this" opens a new preference already mounted on the shape's moment with a
starting trigger.

The rule editor takes an optional preset (kind, moments, trigger) and, opened
outside the Rules view, asks where a new record lives instead of dropping it.
GET /api/retrieval/reply-shapes now returns reply_shapes.overview: the
catalog plus mounted preferences (the same lookup that delivers them) and
counts from the reply_shape delivery rows. #5497 (step 5 of milestone 500).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:19:45 -04:00
bvandeusenandClaude Opus 5.5 b00dc7c4c2 feat(500): reporting-back becomes the long-form reference behind the delivered shapes
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m17s
CI & Build / Python tests (push) Successful in 1m59s
CI & Build / Build & push image (push) Successful in 23s
The reply shapes are server product now, delivered at their moments, so the
skill stops restating them. Gone from it: the "Every reply" list, the
per-kind tables, and "The operator's own shapes come first" (preferences
arrive beside the shape on the same moments). It opens by saying where the
shapes come from (list_reply_shapes, the delivered core's header) and keeps
the reasoning: sections chosen not filled, a settled decision acted on,
placement from the record, who decides what, the assumptions an option
carries, the completion report worked in full, and the second pass. 13.5k to
10.7k characters.

The core gains the Finding kind the skill's table carried (2,150 of 2,200).
Tests follow the content: the kind and Approval-row pins move to the shapes,
a new test holds the worked example and the completion shape to the same
sections, and the guidance-ownership registry reads the delivered shapes as a
surface, owning the preference-wins and length topics there. using-scribe
points at the delivery and list_reply_shapes. #5496.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:55:02 -04:00
bvandeusenandClaude Opus 5.5 f68b73f922 feat(500): retire the fixed-question preference arm - completion-report preferences ride the moments
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m10s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 31s
report_preference searched one constant string when a task closed and handed
matches back as update_task's reply_preferences. Milestone 500 step 3 delivers
the reply shapes and the preferences mounted beside them on the moments, so
the arm goes, whole (rule 22):

- services/reply_preferences.py, the REPORT_PREFERENCE RuleArm, its re-scorer
  and corpus entries, and the REPORTPREF constants
- update_task's reply_preferences block and its cue; the docstring now points
  at moment_rules
- the fixed_query field and the fixed_query_never_clears warning: this arm was
  the only one that set it, so the concept and the cannot_decline exemption
  go with it
- the two Settings fields for its floor and budget
- migration 0121 deletes its rows (logs, judgments, rule-usage events, tuning
  history, settings keys), operator-approved 2026-10-09. Without it the
  readout would call the source unregistered and its surfacings would count
  as ambient. The unindexed rule_usage delete is bounded by created_at.

#5496 (step 4 of milestone 500).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:51:04 -04:00
bvandeusenandClaude Opus 5.5 cee1eb1328 fix(500): the section-hold marker is a ledger by name, so a compaction sweeps it
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 22s
The marker scribe_reply_check.sh writes after a completion-section hold sat in
the swept ledger directory as `<sid>.reportcheck`, and the sweep matches
`.ids` - test_every_session_file_in_a_swept_directory_follows_the_convention
caught it (run 799). Renamed `<sid>.reportcheck.ids` and added to the roster.
#5496.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:42:25 -04:00
bvandeusenandClaude Opus 5.5 6071007fe2 feat(500): one end-of-turn request - the completion-section check folds into the reply check
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Failing after 1m25s
CI & Build / Build & push image (push) Skipped
The Stop hook sent the finished reply twice: scribe_report_check.sh checked a
task-closing reply for the completion sections in shell and reported to
/report-check, and scribe_reply_check.sh sent the same reply to /reply-rules
for the rule hold. Now the reply goes once. When the turn closed a task the
hook adds the close count and ids, and the server runs the section check
(services/report_check, the same three patterns) beside the reply hold,
folding both into one reason.

The report_check adherence log is still written for every checked reply
(milestone 409's number). A section hold marks the session, so its rewrite is
sent back once with rewrite:true to record how it came out, and is never held.
The block reason carries the completion shape's one line and points at
list_reply_shapes rather than at the skill. #5496 (step 4 of milestone 500).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:38:17 -04:00
bvandeusenandClaude Opus 5.5 ae66d83213 fix(500): the hooks decode a \u escape to UTF-8 in any locale and any awk
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m57s
CI & Build / Build & push image (push) Successful in 25s
scribe_json_unescape wrote each escaped code point with sprintf("%c", n),
which writes the character only under gawk in a UTF-8 locale; gawk in the C
locale and mawk in any locale write the single byte n % 256. The server
escapes every non-ASCII character, so "·" reached the session as 0xB7, "—"
as 0x14 and an emoji as a NUL wherever a hook ran without a UTF-8 locale.
The decoder now encodes UTF-8 itself under LC_ALL=C. A new test sends what
the server sends from a bare environment, in three locales (#5495).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:50:29 -04:00
bvandeusenandClaude Opus 5.5 eadb08c347 feat(500): reply shapes are delivered - the core every turn through the ledger, each slice at its moment, reply mounts before the reply (#5495)
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Failing after 1m31s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 56s
- Every turn (/api/plugin/retrieve, UserPromptSubmit): the core reply shape
  leads the payload - in full the first time, as its one-line reminder
  after that - followed by whatever is mounted on reply.report, under the
  shared rule ledger. Fresh keys come back as shape_keys.
- The ledger is <sid>.shapes.ids in scribe-priorart (scribe_shapes_file /
  _seen / _append), so the compaction sweep that clears every .ids ledger
  is what brings the full core back after one.
- At a moment (/api/plugin/moment): the slice for that reply - completion
  on work.finish, asks on reply.ask, plan on work.plan - ahead of the
  mounted rules. reachable_tools now lists tools reaching a shaped moment
  even on an install with nothing mounted.
- Scribe's own tools (attach_moment_rules): reply_shape in the response,
  in full, since that door has no ledger. enter_project carries the core
  for clients with no prompt hook.
- Telemetry: one AppLog row per delivery (plugin / reply_shape), each
  shape with full or pointer and the door (turn, hook, mcp).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:45:25 -04:00
bvandeusenandClaude Opus 5.5 9fedcbea3d feat(500): who decides what - the agent settles what only it can see, the operator decides direction (#5494)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Successful in 1m58s
CI & Build / Build & push image (push) Successful in 27s
The operator's line (2026-10-09), reconciling their 2026-09-21 ruling
(#4218, "you are the judge") with "not that Claude should make all
decisions": judging covers the work and its records - facts, findings,
measurements, whether work is done - and direction stays theirs:
priorities, what the thing should be, trade-offs only they can price,
anything they live with, and every act hard to undo or facing outward.

- reply_shapes: a one-line form in the core (every turn), the full text
  in the asks slice (where a question gets handed back). Slice budget
  1400 -> 1600 for it.
- reporting-back "You are the judge": "the operator reads the decision -
  they do not make it" replaced; the line is drawn by who can see the
  evidence, not by how hard the call is.
- plugin version minted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:35:55 -04:00
bvandeusenandClaude Opus 5.5 3648c78286 fix(500): the completion slice says "two questions", not "two tests" - the domain guard reads the word as software (#5493)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Successful in 1m58s
CI & Build / Build & push image (push) Successful in 1m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:32:05 -04:00
bvandeusenandClaude Opus 5.5 a0390d9da7 feat(500): the reply shapes become server product content - a compact core and per-kind slices, each tied to its moment (#5493)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Failing after 1m29s
CI & Build / Build & push image (push) Skipped
services/reply_shapes.py is the single source of the default reply shapes:
the core (every reply, ~1,800 chars against a 2,200 budget) and three
slices - completion on work.finish, asks on reply.ask, plan on work.plan.
Read through list_reply_shapes (MCP, read-only) and GET
/api/retrieval/reply-shapes, one service behind both doors.

Nothing delivers them yet; that is step 3. The skill still carries its
copy until step 4 shrinks it to the long-form reference.

The software-only vocabulary guard moves into tests/helpers.py
(DEV_ONLY, dev_only_hits) rather than becoming a fourth copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:29:44 -04:00
bvandeusenandClaude Opus 5.5 acda59fc73 ci: integration finds only its own service containers
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m12s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 25s
The runner's docker daemon is shared, so `--filter name=<job> | head -n1` can
pick another repo's Postgres when two jobs run side by side (Steward run 8358,
Inkwell run 8653). Scope the lookup to this job's own task prefix and require
exactly one match. Done from Inkwell #5313 with the operator's permission; the
recipe is rule 79.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:17:10 -04:00
bvandeusenandClaude Opus 5.5 37ca2ee958 fix(lessons): convergence is measured against each lesson's own register, and its members must resemble each other - a flat band of no-rule lessons names nothing (#5193)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 2m1s
CI & Build / Python tests (push) Successful in 2m23s
CI & Build / Build & push image (push) Successful in 52s
Lessons share one register, so any two score well above unrelated text and
a fixed similarity bar sat inside that band: every no-rule lesson
"resembled" most of the others and the named group grew with the pool.

A neighbour now counts only when it stands above the lesson's own
background (median and MAD of its similarity to every lesson it can reach,
in that register's spread), and a group is a clique: every pair clears the
bar from both sides, so one broad lesson cannot join unrelated ones. The
bar, the minimum background and the fetch sizes are stated as defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:58:39 -04:00
bvandeusenandClaude Opus 5.5 890c93e126 feat(scripts): measure_duplication - the DRY close-out measure: before/after duplicate share, and the copies that exist only after (#4745)
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 2m0s
CI & Build / Python tests (push) Successful in 2m40s
CI & Build / Build & push image (push) Successful in 25s
A multi-pass DRY audit could not answer whether it created duplication:
a pass's own shortening can leave two statements identical to a third,
and no per-pass scan sees a copy that did not exist when it ran. The
Librarian retrospective improvised this measure in a scratchpad; this is
it as a tool the DRY Pass process can name.

6-line windows over significant lines (comments, blanks and bare
punctuation dropped, strings folded), grouped by glob or extension.
Revisions are read through git archive, so nothing is checked out.
Stdlib only, so any project can run a scratch copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:24:05 -04:00
bvandeusenandClaude Opus 5.5 5323a1fbc2 fix(family): the repeat trigger compares a record only with its own kind - a snippet with snippets, a note with notes (#5130)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m38s
CI & Build / Python tests (push) Successful in 2m16s
CI & Build / Build & push image (push) Successful in 32s
A dated release log (a note) matched a signed-APK CI snippet at 0.83 on
shared release vocabulary and was opened as a family candidate, then came
back as a precedent. One chunk of a long narrative scores like the shape
it mentions, so a cross-kind score does not mean "the same thing made
twice". The search is filtered to the new record's kind and the loop
checks it too. Code built from an idea written elsewhere still reaches the
engine through the citation trigger and the shape ledger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:32:55 -04:00
bvandeusenandClaude Opus 5.5 f006f15058 fix(family): a decision read as precedent says its reason once - to_brief for precedents and idea history; the decision log keeps the full row (#5131)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m13s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 40s
Each decision stored its reason three times (reason, before.reason, after.reason)
and a promotion carried the whole criteria reasoning in evidence. Precedents
(assess, promote, get_family_adoption, get_family_idea) and get_family_idea's
history now serve FamilyDecision.to_brief(): reason once, before/after as status,
canon_version and platforms, evidence as the cited list. list_family_decisions
and the decision echoed back by a write keep to_dict. The precedent count was
already bounded at five.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:30:48 -04:00
bvandeusenandClaude Opus 5.5 07169e1274 feat(family): an idea links to the rule topic holding its norms - set_family_topic, the readouts name the rules, the idea row shows the link (milestone 463 step 1 gap, found in step 7, #4993)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 1m9s
CI & Build / integration (push) Successful in 1m21s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 1m29s
Step 1 added family_ideas.topic_id but nothing could set it, so the note<->topic
link the milestone promised existed only as a column. set_family_topic links or
unlinks (0) a topic in a rulebook the caller owns, one idea per topic; the
version does not move. get_family_idea and get_family_adoption list the topic's
rules; list_family_ideas and the Family page name the topic. The model
docstrings no longer claim the topic's rules are platform-scoped in retrieval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:27:33 -04:00
bvandeusenandClaude Opus 5.5 b8efff43b4 fix(family): the reach tests assert on their own corpus; the shared integration database holds other tests' canon on the same platform (#4992)
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 2m4s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / Build & push image (push) Successful in 34s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:35:42 -04:00
bvandeusenandClaude Opus 5.5 07e21c7ff9 feat(family): family canon reaches the session - entry readout, retrieval reach, skill, report cue (milestone 463 step 6, #4992)
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Successful in 2m39s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Failing after 1m53s
- enter_project carries a `family` key, but only when the project has
  something to answer: counts of unassessed, owed and to-recheck answers,
  each with the list_family_adoptions call that lists it. It shows on every
  entry, never by platform touch: entry is when work is chosen, and an
  unanswered idea is otherwise invisible.
- Retrieval: a widened project search (include_global_kinds) now also
  reaches the canon ideas on the project's platforms. It also reaches their
  references in the project's languages, or all of them when none matches.
  An off-platform project gets none, and the plain project filter (the
  duplicate gate) is unchanged.
- Closing a task returns `family_owed`, the owed answers filed while it was
  open, and the report cue asks for them to be named.
- New plugin skill family-canon (moment work.record) covers when to
  evaluate a promotion, answering in order, what counts as a reason, the
  precedent reflex and the conflict order. _INSTRUCTIONS, create_note,
  create_snippet, classify_shapes and reporting-back point at it.
  The plugin version is minted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:27:39 -04:00
bvandeusenandClaude Opus 5.5 5f41dbd283 feat(family): the shape ledger judges against family ideas, across languages (milestone 463 step 5, #4991)
CI & Build / TypeScript typecheck (push) Successful in 58s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 1m26s
CI & Build / Python tests (push) Successful in 2m9s
CI & Build / Build & push image (push) Successful in 1m27s
- classify_shapes takes idea_id: the shape is judged against the idea's
  reference in its language, or its first.
- A shape classified against a canon idea's reference moves the project's
  adoption row (instance -> adopted, variant -> variant). Withdrawing the
  shapes that gave an answer returns it to unassessed. This runs on
  classify_shapes, the sweep, confirm_proposals and the coverage refresh.
- assess and undo refuse an answer the shapes contradict. A conflict
  resolution re-judges the variant shapes on both sides.
- The proposer's family arm offers a canon idea's reference on a shared
  platform, in any language. It proposes only when the reference is the top
  hit over every readable snippet and scores at least 0.70. The pairs this
  was measured on are recorded beside _FAMILY_FLOOR. The proposer is now v6.
- The matrix cell shows the code that answers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:06:45 -04:00
bvandeusenandClaude Opus 5.5 233fa3eea9 fix(family): the owed task is tagged by the matching System only, and the ground fixtures build without duplicate keys (#4990)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m57s
CI & Build / Build & push image (push) Successful in 44s
assess_family_adoption took system_ids for the task it files, which made
the family tool module look like a System-tagging door and tripped the
parity registry. It is not one: the owed task is tagged to the project's
System matching the idea's canonical area, and update_task retags it.
The unit test for each conflict ground passed evidence/conditions twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:11:41 -04:00
bvandeusenandClaude Opus 5.5 201e09901b feat(family): the adoption ledger - assessment, the conflict order, owed->task, recheck, the adoption matrix (milestone 463 step 4, #4990)
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Failing after 1m28s
CI & Build / Build & push image (push) Skipped
- services/family_adoption.py: assess one project against one canon idea by
  the four outcomes in order (exempt, variant, adopted, owed). Every outcome
  needs a reason and adopted needs evidence. The engine records the precedents
  itself: this idea's answers elsewhere, and this project's answers to the
  nearest ideas. The same answer given twice records nothing.
- owed files a task in the OWING project, tagged to the System matching the
  idea's canonical area, naming the gap and the reference for that project's
  language. The task follows the answer: adopted closes it, exempt or variant
  cancels it, owed again reopens it. Each move is logged on the task.
- the conflict order is enforced: every ground above the deciding one must
  say why it did not decide. The losing side is folded into the idea's note as
  a trap, an alternative or a condition branch, the version moves, and both
  rows are answered against the revision.
- recheck is derived (row version != idea version). family.revise moves the
  version when substance changes. undo covers a project's latest answer too.
- set_family_references names the reference implementations.
- MCP: get/list/assess adoption, resolve_family_conflict, revise_family_idea,
  set_family_references. Web: GET /api/family/matrix.
- UI: an adoption matrix on /family (platform filter, cell detail with reason,
  recheck and owed-task link) and the same matrix narrowed to one project on
  its Family tab. The decision log now reads project-level decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:05:00 -04:00
bvandeusenandClaude Opus 5.5 faa1b72307 fix(family): undoing the decision that created an idea leaves no applicability or scope behind (#4989)
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / Build & push image (push) Successful in 46s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m56s
When the undone decision had no recorded before-state, the synthesized prior copied the canon idea's current applies_when and platforms into a retired row. Before that decision the record was not an idea, so the restored state now has neither. Caught by test_undoing_a_promotion_restores_the_prior_state_and_keeps_judged_rows (run 8281).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:37:37 -04:00
bvandeusenandClaude Opus 5.5 8aacc1824c feat(family): the promotion engine - triggers, the three criteria, the decision log and undo (milestone 463 step 3, #4989)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Failing after 1m5s
CI & Build / Python tests (push) Successful in 1m58s
CI & Build / Build & push image (push) Skipped
The agent promotes a family idea when all three criteria hold, and no person approves it. The criteria are product text: services/family.py states them, and the promote tool's docstring names every criterion the service enforces.

- Criteria: each one vetoes on its own when its reasoning is blank. Platform terms also needs an applies-when and a platform scope; proven also needs named evidence. A veto keeps the idea a candidate and is logged, so it becomes precedent.
- Precedent: every promotion stores the decisions on the nearest ideas by meaning, plus any the caller names.
- Promotion sets canon, the applicability test and the platform scope, and opens an unassessed ledger row for each member project the promoter can write. Re-promotion moves the version past every version the idea has held.
- Retire and undo: undo reverses only the latest idea-level decision, restores its recorded before-state, and logs itself with the undone decision as its precedent. Settled here: leaving canon closes the unassessed rows but keeps the judged ones, which read as needing a recheck after a re-promotion.
- Triggers open evaluations but never promote:
  - a cross-project lineage citation ("matching #N") on a note or task write;
  - a same-meaning record in another project on a shared platform, on create, at 0.80 (measured: the known pattern's builds scored 0.79-0.82, an unrelated project's best match 0.65);
  - a milestone closing on a platform.
  Each fails open and rides the response as family_hint.
- Doors: seven MCP tools, /api/family REST endpoints, and a Family page (nav, /family) showing the criteria, the ideas, and the decision log with undo and retire.
- utils/recordHref.ts holds the one copy of "where a record opens", now shared with LessonDetailView.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:33:30 -04:00
bvandeusenandClaude Opus 5.5 1774ee3696 fix(frontend): each shared <style src> sheet sits at one block index everywhere, so vite build cannot depend on transform order (#4988)
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m40s
CI & Build / Python tests (push) Successful in 2m37s
CI & Build / Build & push image (push) Successful in 56s
Run 8271 failed in the image build: "[vite:vue] Cannot read properties of undefined (reading 'scoped')" on moments-shared.css. plugin-vue caches one descriptor per src file and answers ?index=N from whichever component registered it last. RuleEditorSlideOver had moments-shared at block 2, while its other two importers have it at block 1. Step 2's new imports changed the transform order and exposed the bug.

- RuleEditorSlideOver: moments-shared is now loaded with an @import instead of a third <style src>.
- rules-shared.css sat at block 0 in the five panes and at block 1 in LessonDetailView, LessonEditorView and RuleEditorSlideOver, which is the same latent hazard. The three move to block 0.
- tests/test_frontend_shared_styles.py: a guard that every <style src> sheet sits at one index, plus a test that the guard can fail. vue-tsc cannot see this hazard, and vite build only runs after every test has passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:58:41 -04:00
bvandeusenandClaude Opus 5.5 07d2542479 feat(family): platforms declared at inception and detected from bound repos (milestone 463 step 2, #4988)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 1m10s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Python tests (push) Successful in 2m40s
CI & Build / Build & push image (push) Failing after 44s
A project's platforms decide which family ideas reach it. This step makes membership answerable from every door:

- services/platforms.py: the global catalog (writes are admin-only and duplicate-gated by slug); pure marker detection; and membership reads and writes. Detection only ADDS, and only where nobody has answered. It never overrides a declared or rejected row and never removes one.
- coverage: the archive scan now carries every path, and the refresh runs detection fail-open.
- inception: a platforms choice (slugs, or null for unanswered). The list is the whole answer: members left out of it become rejected.
- MCP: list_platforms and set_project_platforms; enter_project and get_project carry the project's platforms.
- REST: /api/platforms (admin writes) and /api/projects/<id>/platforms.
- UI: a platforms checklist on the inception card, a Family tab on ProjectView, and a Platforms admin tab in Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:51:28 -04:00
bvandeusenandClaude Opus 5.5 e68ccc5884 feat(family): platforms, family ideas, the adoption ledger and its decision log (milestone 463 step 1, #4987)
CI & Build / TypeScript typecheck (push) Successful in 1m11s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 25s
CI & Build / integration (push) Successful in 1m50s
CI & Build / Python tests (push) Successful in 2m41s
CI & Build / Build & push image (push) Successful in 1m8s
When one project solves something every project on the same platform will
meet, that solution becomes family canon and every other project on the
platform answers it. This is the storage for that.

- platforms: a global catalog in the canonical_systems shape, seeded with
  generic technology names and the file markers step 2's detection reads.
- project_platforms: declared / detected / rejected. A rejected row is kept
  so detection cannot re-add what a person said no to.
- family_ideas: a note's family state. No new record type; any note, snippet
  or lesson becomes an idea. A canon idea must state when it applies (CHECK).
- family_idea_platforms: the only scope source. A linked rule topic takes its
  scope from the idea, so the two cannot disagree.
- family_idea_references: reference implementations, explicit not inferred.
- family_adoptions: one answer per (project, idea). Variant and exempt require
  a reason (CHECK). Recheck is derived from the two canon versions, never
  stored.
- family_decisions: the append-only log, with a required reason and the
  earlier decisions each one followed. The agent decides with no approval
  step, so precedent is what keeps its calls consistent.

Backup v25 carries all seven: platforms by slug, precedent ids remapped
through the decision map. Both column guards cover the new tables, and a
real-Postgres test exercises the CHECKs and the restore remaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:33:27 -04:00
bvandeusenandClaude Opus 5.5 ccbccb025c fix(retrieval): every semantic search scopes first, then ranks - the note, milestone and system searches join the rule search on one shared shape, _rank_scoped (#4961)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m0s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 27s
Ordered straight off a *_embeddings table, the planner walks the HNSW index,
takes ~ef_search (40) nearest chunks across every owner and project, and only
then applies the scope: an in-scope record behind 40 nearer ones the caller
cannot see was silently dropped. #4958 fixed the rule search alone; the note,
milestone and system searches kept the fault.

_scoped_chunks builds the in-scope chunks with their distance; _rank_scoped
materializes them as a CTE and ranks exactly. All four searches go through it,
and the row shape is unchanged, so callers and mocks are untouched.

Tests: a structural guard that every semantic_search_* ranks through
_rank_scoped and orders nothing itself (with a replay of the old shape), a
compiled-SQL check of the MATERIALIZED CTE, and integration crowd tests - 80
nearer out-of-scope records - for notes (another user; the reader own other
project), milestones and systems.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:08:45 -04:00
bvandeusenandClaude Opus 5.5 d2fa723373 refactor(retrieval): the rule builders compose one moment - _rule_moment runs the arm and its via-lesson step for all three (milestone 456 step 6, #4908)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 28s
build_prompt_rule_hint, build_tool_rule_hint and build_write_path_hint
each ran the rule arm and then the via-lesson step by hand, the first two
passing the query between them through a _via_query key on the payload.
They now call one composer, _rule_moment, and the split helpers
(_prompt_rule_hint, _tool_rule_hint, _add_rules_via_lessons) and the side
channel are deleted. Output shapes are unchanged: the prompt builder
returns no checkpoint key, the tool builder always does, and
shown_rule_ids stays the direct band.

A structural test pins that only _rule_moment runs a rule arm or the
via-lesson step, and that the three builders are its only callers.

plugin_context.py: 2,418 -> 2,361 lines this step; 3,558 when the
milestone began.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:25:47 -04:00
bvandeusenandClaude Opus 5.5 869046dda2 refactor(retrieval): the specs are the registry - SURFACES, the ranked POINTS rows and RANKED_SOURCES are read off the pipeline specs (milestone 456 step 5, #4907)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 2m1s
CI & Build / Build & push image (push) Successful in 27s
Each arm spec now carries its tuning pair (Surface, moved verbatim into
retrieval_pipeline) and a Declared block - what the telemetry readout
must know and cannot read off its rows. The two ranked stages that are
not arms (preference_slot, rule_via_lesson) are RankedSource specs, and
the note slots carry their own declaration.

- retrieval_surfaces.SURFACES = the TUNED_ARMS tuning, same order
- retrieval_registry.POINTS ranked rows = one per spec in RANKED; the
  lookups, asked, ambient and pull rows stay declared there
- rule_usage.RANKED_SOURCES = RULE_RANKED_SOURCES + moment_rule

Settings keys, defaults, prose and order are unchanged (checked field by
field against HEAD). tests/test_retrieval_specs.py pins the derivation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:11:20 -04:00
bvandeusenandClaude Opus 5.5 2b8f41229d refactor(retrieval): the notes arms run on the one pipeline - auto_inject, its reuse and lesson slots, the write path by meaning, and rule_via_lesson are specs (milestone 456 step 4, #4906)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Successful in 1m52s
CI & Build / Build & push image (push) Successful in 35s
retrieval_pipeline gains the notes half: NoteArm / NoteSlot / NoteMoment /
NoteIO / NoteResult and run_note_arm, which writes once the stages both
notes arms copied: search, withhold this response's own menu (#3739),
fresh/repeat split, the call row before any return (#3497, #3752), the
band, the reserved slots in their order (reuse evicts, lesson extends),
and the surfacing rows. The note renderer (_record_kind, _menu_name,
_menu_passage, the seen pointer, menu_entry) moves with it, and
run_via_lesson_arm takes rule_via_lesson.

Behaviour-preserving, with flags for today's differences: notes still log
BEFORE the band and rules after it (step 7's question). One deliberate
change: a notes arm now fails open like the rule arms, so a failing
search costs its lines and no longer the whole hook response.

The I/O is resolved from plugin_context at call time (_note_io), so the
existing patches keep working. The review re-run reads the AUTO_INJECT
spec instead of restating it, and its guard now compares the two live
searches. The registry declares the pipeline's notes fan-out sites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:02:37 -04:00
bvandeusenandClaude Opus 5.5 4b1060ae8b Revert the deliberate integration failure - the gate was watched rejecting run 8218 (rule 177, #4958)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / integration (push) Successful in 53s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 19s
Run 8218: integration failure, Build & push skipped, :dev still package
version 11098 (23:36:29Z, run 8217), and no image tagged 9a76eb0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:42:04 -04:00
bvandeusenandClaude Opus 5.5 9a76eb0a34 test(ci): TEMPORARY deliberate integration failure, to watch the publish gate reject a red run (rule 177, #4958)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Failing after 1m1s
CI & Build / Python tests (push) Successful in 1m51s
CI & Build / Build & push image (push) Skipped
Reverted next. Expect: integration red, Build & push skipped, :dev unmoved,
no image tagged with this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:38:13 -04:00
bvandeusenandClaude Opus 5.5 17f4348711 ci: the image build waits on the integration lane (rule 177, #4958)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 18s
The integration job was added after the build gate (c6211e5) and never
joined its needs, so main run 8212 published :latest with integration red.
Both jobs carry the same ref condition, so the gate cannot be satisfied by
a skipped integration run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:34:16 -04:00
bvandeusenandClaude Opus 5.5 983fd2c4d1 fix(retrieval): scope the rule search before ranking it - an in-scope rule is no longer lost behind other owners' nearer rules (#4958)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 20s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m11s
CI & Build / Python tests (push) Successful in 2m0s
CI & Build / Build & push image (push) Successful in 29s
Ordered straight off rule_embeddings, the planner walked the HNSW index,
which returns about hnsw.ef_search (40) nearest chunks across every owner
and project and filters by home only afterwards. A reader's own rule ranked
past the 40th chunk overall was silently dropped - on a shared install,
other users' rules fill those 40. This is the likely cause of the
intermittent test_integration_rule_scope failures, whose axis-vector
fixtures sit far from every real embedding in the graph.

The in-scope chunks now go through a MATERIALIZED CTE, which the index
cannot order, so the ranking over them is exact. A rulebook is hundreds of
chunks; exact is cheap. New test: 80 nearer rules belonging to someone else
no longer hide the reader's one rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:30:05 -04:00
bvandeusenandClaude Opus 5.5 79cd2341a6 test(rules): the scope test says why a rule search returned nothing - an error or an empty scan (#4958)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m6s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 18s
semantic_search_rules fails open, so both failures on run 8209 and main
run 8212 read as set() with no trace. Assert report["searched"] and carry
the swallowed traceback into the failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:34:16 -04:00
bvandeusenandClaude Opus 5.5 4e1320120d feat(moments): a mount that keeps arriving where it does not apply proposes its own removal (milestone 458 step 7b, #4955)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 2m3s
CI & Build / Build & push image (push) Successful in 18s
The open-after-moment signal proposes a mount; nothing proposed taking one
off, so a wrong mount was noise at every occurrence until someone happened
to notice. rule_misfired(rule_id, moment, why, reached_by) records a report
against a MOUNTED pair, counted per distinct day (the MCP door carries no
session id) on a new rule_moment_judgments.misfire column (migration 0119,
backup v24). At three days the response carries a line asking the agent to
offer the operator the fix - reject takes the rule off, unmap_action stops
the action reaching the moment, confirm keeps the mount and stops the
asking - and Settings > Moments lists it as an unmount proposal with the
reasons and the actions that reached it. A re-mount clears the count.

Taught in moments.md, missed-retrieval.md and the reply hold's wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:19:59 -04:00
bvandeusenandClaude Opus 5.5 8afd6da8af docs(plugin): reference files point at each other in words, not links - one level deep (milestone 458 step 8, #4926)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 2m11s
CI & Build / Build & push image (push) Successful in 41s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:44:22 -04:00
bvandeusenandClaude Opus 5.5 a72a422534 docs(plugin): the instruction surfaces teach moments - reading a line that arrived at one, correcting a misfire, and giving a new rule its moments (milestone 458 step 8, #4926)
CI & Build / Python lint (push) Successful in 13s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m38s
CI & Build / Python tests (push) Failing after 1m59s
CI & Build / Build & push image (push) Skipped
Until now only the tool arguments knew moments existed. The guidance
surfaces described rules as reached by resemblance alone:

- using-scribe: a short reflex paragraph and a new reference file,
  moments.md. It covers reading "at <moment>, reached by <action>", the
  reply held once at reply.report, map_action / unmap_action offered in
  one line, and the step 7 proposal line answered with judge_rule_moments.
- writing-records: asks WHEN a rule applies as well as what it is about.
  A rule, preference or process about a point in the work gets
  moments=[...] as it is written, and the trigger stays as the net.
- missed-retrieval: a missed WHEN is mounted or mapped, not reworded. A
  misfire is unmounted or unmapped.
- _INSTRUCTIONS: one clause (list_moments; mount rules about WHEN),
  1594 of 1600 chars.
- static context: injected lines include the rules mounted on a moment
  that was reached.
- test_guidance_ownership: three owned topics, so the text cannot quietly
  drop out.

Plugin minted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:39:47 -04:00
bvandeusenandClaude Opus 5.5 c404a3127a test(retrieval): the proposal endpoints are routed on the app (milestone 458 step 7, #4925)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m2s
CI & Build / Python tests (push) Successful in 2m17s
CI & Build / Build & push image (push) Successful in 56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:16:59 -04:00
bvandeusenandClaude Opus 5.5 570d1b6d5a test(backup): register the rule_moment_judgments builder in the import column guard (milestone 458 step 7, #4925)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 56s
CI & Build / Python tests (push) Failing after 1m44s
CI & Build / Build & push image (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:12:30 -04:00
bvandeusenandClaude Opus 5.5 dfcf4df2e9 feat(moments): mount the corpus by proposal - a pass and an open-after-moment signal, both stopping at the operator (milestone 458 step 7, #4925)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / integration (push) Failing after 43s
CI & Build / Python tests (push) Failing after 46s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Build & push image (push) Skipped
A rule written before moments existed is mounted on nothing. Step 7 records,
per (rule, moment), whether it belongs there and who said so:

- rule_moment_judgments (migration 0118, backup v23): suggested / confirmed /
  rejected, from a pass, the signal, or an edit. Moment "" is "no moment fits".
- The pass: rules_to_mount lists unjudged rules; propose_rule_moments records
  suggestions that mount nothing; rule_moment_proposals and
  judge_rule_moments put them to the operator. A confirm mounts, a reject is
  kept so the pair is never proposed again. Same service behind REST and a
  "Waiting on you" panel in Settings > Moments.
- Edits are judgments: set_rule_moments, the one mount write path, confirms
  what was added and rejects what was removed in the same transaction.
- The signal: scribe_moment.sh keeps a per-session acts ledger; when a rule
  is opened, scribe_record_opened.sh sends the last three minutes of it to
  /api/plugin/rule-opened. The acts resolve through the install's mappings;
  work.run and work.change are not evidence. Counted per distinct session
  with lesson_rules' evidence model, and once due the open returns one line
  asking the reader to offer the mount.
- scribe_session_end.sh removes the session's scribe-moment files.

Plugin 2026.10.05.2003.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:03:53 -04:00
bvandeusenandClaude Opus 5.5 5bcc603310 fix(moments): the store loads rather than fetches, so the deadline guard does not read it as a bare fetch (milestone 458 step 6, #4924)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:47:12 -04:00
bvandeusenandClaude Opus 5.5 b73a689849 feat(moments): the human door onto mounts, mappings and per-moment telemetry (milestone 458 step 6, #4924)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m34s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 2m7s
Everything an agent can do with moments, a person can now see and change
in the app.

- Rule editor: a moment picker beside the trigger. Catalog moments are
  ticked; a named procedure's `skill.<name>` is typed and checked as the
  server checks it. `moments` is always sent, so unticking the last moment
  unmounts the rule.
- Settings, Moments section (General tab): for each moment, what it
  means, the actions that reach it on this install (shipped ones can be
  switched off, the install's own removed), how many rules are mounted on
  it, and deliveries and agent opens over the window. Below that: named
  procedures with mounts, switched-off defaults with Restore, and a form to
  add an action.
- retrieval_telemetry.moment_usage: per moment, `delivered`, `rules`,
  `opened` (agent pulls after the first delivery there; an upper bound, as
  by_source is) and `last_delivered_at`. No ratio, because a mount is a
  person's statement, not a ranker's guess. Guarded on its own, and also
  reported in retrieval_summary as `moment_usage`.
- rulebooks.mount_counts; mounted_moments now derives from it.
- GET /api/retrieval/moments carries `mounted` and `usage` (?days=).
  DELETE /moments/mappings also reads the mapping from query parameters,
  since the browser's DELETE sends no body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:42:30 -04:00
bvandeusenandClaude Opus 5.5 c489452a2d test(moments): an install removal still drops its tool while the skill loader stays listed (milestone 458 step 5, #4923)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 1m2s
CI & Build / integration (push) Successful in 1m20s
CI & Build / Python tests (push) Successful in 2m12s
CI & Build / Build & push image (push) Successful in 33s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:28:50 -04:00
bvandeusenandClaude Opus 5.5 f1fdc4a951 feat(moments): skills and stored processes declare the moments they are for (milestone 458 step 5, #4923)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Failing after 1m25s
CI & Build / Build & push image (push) Skipped
Loading a procedure now also reaches the moment it is for. Loading the
reporting procedure is a report; loading the release procedure is a delivery.

- Bundled skills: each SKILL.md declares `metadata: moments:`. The same
  declaration ships as Skill defaults (BUNDLED_SKILL_MOMENTS), because the
  server never sees the plugin's files. test_skill_moments holds the two
  together and pins the plugin name that qualifies the skill.
- Stored processes: `moments` on create_process and update_process, stored
  in the note's data and returned by get_process. A `scribe-proc-<slug>`
  load resolves its process through the sync manifest at load time. The
  moments are not copied into the stub, which would go stale mid-session.
- reachable_tools lists the skill loader whenever anything is mounted, since
  a process's moments are known only when it loads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:23:24 -04:00
bvandeusenandClaude Opus 5.5 b29689d4de fix(retrieval): declare the reply moment's surfacing site in FAN_OUT_SITES (milestone 458 step 4b, #4922)
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python tests (push) Successful in 1m50s
CI & Build / Build & push image (push) Successful in 40s
moment_delivery records the mounted half's surfacing under
source=rp.MOMENT_RULE_SOURCE. That is an attribute the registry's
extractor cannot resolve, so the site is now declared with the value it
emits, read from the pipeline's own constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:32:07 -04:00
bvandeusenandClaude Opus 5.5 c6cdfc2172 feat(moments): the reply moment holds a finished reply for one read (milestone 458 step 4b, #4922)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / integration (push) Successful in 1m1s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Python tests (push) Failing after 1m25s
CI & Build / Build & push image (push) Skipped
The reply is the one act no tool call marks, and it is where "let me
know if it works" gets said. A new Stop hook (scribe_reply_check.sh)
sends the finished reply to POST /api/plugin/reply-rules, which checks
it twice:

- mounted: every unopened RULE on reply.report, plus reply.ask when the
  reply asks a question. Deterministic.
- semantic: the reply's head and tail against every rule's trigger, on a
  new ranked surface, reply_rule. It is the backstop for whatever the
  earlier arms missed. Its floor is its stop bar (default 0.80, budget
  1), with its own Settings dials. The new stop_only stage records
  surfacing for the rule that holds and nothing else, because nothing
  else reached anyone.

Following the operator's ruling from 456 step 8, a rule that holds blocks
once, in the server's words. The hook blocks only on a reason it was
given, so an unreachable instance never stops a session, and it never
holds the rewrite. The ledger is the act checkpoint's own, so a rule
holds a session once across both doors and the per-session cap counts
both.

The turn reader moved from the report check into scribe_defs.sh
(scribe_turn_facts / scribe_turn_fact), so the two Stop hooks read a
turn the same way. The output was checked identical on a real transcript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:30:07 -04:00
bvandeusenandClaude Opus 5.5 b3b616b20a fix(moments): the tools import the delivery module, and the tool-list cache leaves the swept directory (milestone 458 step 4a, #4922)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m48s
CI & Build / Build & push image (push) Successful in 32s
Two guards caught 7865313:

- test_mcp_tool_processes reads every coroutine in a tool module's
  namespace as a tool, and a name-imported attach_moment_rules looked
  like one. All seven modules now call moment_delivery.attach_moment_rules,
  and the parity guard accepts the attribute form.
- The session-ledger convention: a file in a swept directory must be a
  .ids ledger. The tool-list cache describes the install, not the
  context, so it moves to its own directory, scribe-moment, where a
  compaction does not sweep it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:24:30 -04:00
bvandeusenandClaude Opus 5.5 78653130d6 feat(moments): mounted rules arrive when their moment happens, through every door (milestone 458 step 4a, #4922)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 57s
CI & Build / Python tests (push) Failing after 1m19s
CI & Build / Build & push image (push) Skipped
A rule mounted on a moment now reaches the session when an act reaches
that moment, with no semantic match involved:

- run_moment_arm on the pipeline: a lookup, not a ranked search. Each
  line names the moment and the act that reached it ("at work.deliver,
  reached by `git push`"), so a misfire is visible where it lands and
  can be unmapped in-session. A repeat is cited, not quoted; fresh
  rules are recorded surfaced under source moment_rule with the moment
  in detail. No retrieval_logs row, as for the other lookups, so no
  latency is persisted for this arm.
- rule_scope: a rule's home clause, moved out of semantic_search_rules
  so the moment lookup scopes by the same one.
- rulebooks.rules_on_moments / mounted_moments.
- The plugin door: a catch-all PreToolUse hook (scribe_moment.sh). It
  keeps /moment-tools' answer on disk for five minutes, so a call to a
  tool that cannot reach a mounted rule sends nothing, and an install
  that has mounted nothing sends one request per window. It shares the
  rules ledger with the other arms and fails open silently.
- The MCP door: Scribe's own tools named by the shipped mappings carry
  moment_rules in their response, so a client without the plugin gets
  them too. The hook skips those tools. A guard pins the attach on
  every one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 12:21:45 -04:00
bvandeusenandClaude Opus 5.5 cc26054437 feat(moments): rules mount on moments, through every rule door (milestone 458 step 3, #4921)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m0s
CI & Build / Python tests (push) Successful in 1m51s
CI & Build / Build & push image (push) Successful in 29s
rule_moments (migration 0117) records which moments a rule arrives at, by
catalog name, cascading with the rule. rule_detail, the one seam every
rule door already returns through, gains moments beside system_ids: None
leaves the mounts alone, a list replaces them. get_rule and both
list_rules doors read them back, batched per page.

All five MCP rule/preference writes and the three REST ones take moments
and validate them before their create or update. An unknown name is
refused with the catalog listed and leaves no half-made rule behind; a
parity test pins that ordering on every door.

Backup v22 carries the mounts as a join table remapped through the rule
map; a real-Postgres round trip checks they land on the restored rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:03:51 -04:00
bvandeusenandClaude Opus 5.5 cf3de5bae1 feat(moments): actions map onto moments, with in-session corrections (milestone 458 step 2, #4920)
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m23s
CI & Build / Python tests (push) Successful in 2m0s
CI & Build / Build & push image (push) Successful in 36s
moment_actions.resolve(tool, input) names every moment a call reaches and
the action that reached it. One call can reach several: kubectl apply is
a run, a deliver and a reach outside the workspace. Command tools match by
how each segment of the line starts, with a word boundary; other tools by
field=value arguments. The MCP server prefix and case are ignored.

56 shipped defaults cover the harness tools, Scribe tools and common
command shapes. moment_mappings (migration 0116) holds what an install
adds and the defaults it switches off. A removal is a stored row, so an
upgrade does not switch the default back on.

Per the operator ruling, corrections happen in the session: map_action
and unmap_action (write tools) return now_reaches so the fix can be
confirmed in the same reply. list_moments now shows each moment's
actions on this install. REST mirrors both doors, recorded as human.
Backup v21 carries the mappings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:58:00 -04:00
bvandeusenandClaude Opus 5.5 2ff7f2f34f feat(moments): the moment catalog rules will mount on, readable in-session (milestone 458 step 1, #4919)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 58s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 32s
Fourteen generic moments of work (session.start, work.start … reply.ask)
plus the skill.<name> family, each with what it means and the kinds of
action that reach it, written for any kind of work rather than software
alone. The catalog is code because every install needs the same mount
points; which actions reach a moment is per-install data (step 2).

require_moment refuses an unknown name with the catalog listed, so a
typo cannot become a mount that never fires. list_moments (read-only)
and GET /api/retrieval/moments hand out the same catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 10:50:45 -04:00
bvandeusenandClaude Opus 5.5 0720ab6dcf refactor(retrieval): the completion-report preferences run through the pipeline (milestone 456 step 3, #4905)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m45s
CI & Build / Build & push image (push) Successful in 28s
reply_preferences.completion_preferences was the last hand-written
rule search + record_retrieval + record_rule_surfaced triple outside the
pipeline. It is now REPORT_PREFERENCE, a RuleArm with kind="preference"
over COMPLETION_QUERY, read back as records rather than as lines.

- RuleArm gains `kind`. _ranked asks the ranker for that kind and drops
  anything else on the way out, before the row is logged.
- RuleResult gains `shown`, the (score, rule) pairs behind the lines, for
  callers that return records.
- RuleMoment.project_id may be None: logged as given, searched as
  `project_id or None`. report_preference rows keep their NULL project.
- Guards: reply_preferences now has 0 direct rule searches. The registry
  constant-source example moves from report_preference to preference_slot,
  the pipeline slot that records through a module constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:38:05 -04:00
bvandeusenandClaude Opus 5.5 b72c9a92e2 test(retrieval): two source guards read the pipeline where the rule arms now live (milestone 456 step 2, #4904)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m45s
CI & Build / Build & push image (push) Successful in 30s
CI run 8137 had two failures, both source-scanning guards whose property
moved to the pipeline. The property itself still held:

- test_every_surface_name_is_a_real_telemetry_source looked for
  source="<name>" literals. The rule arms now record under their spec field,
  so the spec is the join key, read from RULE_ARMS.
- test_every_hook_rule_search_says_which_project_it_is_for counted 4 direct
  searches in plugin_context. It now expects 0 there, so a copied arm fails
  the count. It also asserts that the pipeline has exactly one search and
  that its keyword literal carries project_id and never everywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:07:26 -04:00
bvandeusenandClaude Opus 5.5 f6b824b214 refactor(retrieval): the three rule arms run through one pipeline (milestone 456 step 2, #4904)
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / Python tests (push) Failing after 1m28s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 1m15s
prompt_rule, pre_tool_rule and the rule half of the write path each wrote
the same steps out by hand: search, band, split fresh from repeats, log the
call before any early return, reserve a slot, render, record surfacings.
The copies drifted, and #3497, #3750 and #3752 were fixed one copy at a time.

- New services/retrieval_pipeline.py. run_rule_arm runs the stages in one
  order. RuleArm is the spec (source, band, compact_tail, checkpoint,
  preference_slot). RuleMoment is the query and the session ledger.
- The I/O (ranker and recorders) is passed in as RuleIO. plugin_context
  resolves it from its own names at call time, so existing patch points
  still apply.
- _rule_band, _rule_hint_line, checkpoint_for, checkpoint_reason, the band
  constant and the preference slot moved into the pipeline unchanged.
  plugin_context re-exports them.
- A recorder that raises now costs only its row, never a rendered line.
- The flags reproduce today exactly. Whether prompt_rule should band, and
  whether the act arms should reserve a preference, are step 7.
- Registry: the pipeline call sites are declared in FAN_OUT_SITES, with
  values read from the specs.
- The #3497 structural guard now checks the one implementation, and that
  plugin_context writes no rule-source row of its own.

plugin_context.py: 3,558 -> 2,980 lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 08:01:23 -04:00
bvandeusenandClaude Opus 5.5 be62abf142 perf(retrieval): one embedding per query per plugin request (milestone 456 step 1, #4903)
CI & Build / Plugin hooks (push) Successful in 17s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m25s
CI & Build / Python tests (push) Successful in 2m6s
CI & Build / Build & push image (push) Successful in 1m25s
A single plugin request fans one query out to several ranked arms. The
operator message is searched by auto_inject, its reuse and lesson slots,
prompt_rule, the preference slot and rule_via_lesson, and each arm called
get_embedding on its own: up to six model calls for one vector.

- embeddings.query_embedding_memo(): a request-scoped ContextVar memo that
  get_embedding consults. Outside a scope the default is None, so every
  other caller is unchanged. A failed embedding is not remembered.
- memoized_query_embeddings decorates /retrieve, /tool-rules and
  /prior-art.
- get_embeddings (document chunks) is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:55:06 -04:00
bvandeusenandClaude Opus 5.5 d2ac7bf220 fix(lessons): a lesson's name is one claim on one line (#4797)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m48s
CI & Build / Build & push image (push) Successful in 28s
#4797 "A lesson's what takes a whole narrative, and the narrative becomes
its name". `what` is the title every listing and menu prints. Nothing
enforced its documented "one line", so lessons written with the incident
in `what` printed up to ~1,500 characters as a menu line, buried the
claim, and diluted the trigger in the embedded title. That was 14 of the
39 lessons on this install.

- lessons.require_claim refuses a `what` over WHAT_MAX_CHARS (240) or
  running over several lines. The refusal says the story goes in
  `insight`.
- Both doors call it before writing:
  - MCP create_lesson / update_lesson;
  - REST create / update.
  An update checks only a NEW name, so a lesson stored with a long one can
  still take the edit that repairs it.
- lessons.claim_line is the display half. _menu_name shows an over-long
  stored name as its first sentence marked " …", because a door guard
  does not undo rows already stored and an unrepaired install would keep
  printing them.
- The tool docstrings state the bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:04:53 -04:00
bvandeusenandClaude Opus 5.5 9909cd2450 fix(retrieval): the rules never-opened warning stops sending the reader to a review tool that refuses rules (#4798)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 26s
#4798 "The rules corpus's surfaced_never_pulled warning sends the reader to
menus_to_review, which can only review auto_inject". #4772 gave the warning
one remedy for both corpora: "judge a sample with menus_to_review". That is
right for notes, where a line carries its passage. For rules it is a dead
end: retrieval_review.REVIEWABLE holds only auto_inject, so the tool
refuses every rule arm.

- The reading is now per corpus (_NEVER_PULLED_READING). For rules, the
  text says:
  - the count includes rules that only arrived in a listing;
  - a rule can rightly be set aside on its trigger alone;
  - no judged sample exists for the rule arms;
  - a rule set aside again and again is a trigger to fix (update_rule
    when_to_apply, then what_might_apply), not a floor.
- The tool docstring says the same.
- The guard is tied to REVIEWABLE, so it can fail in both directions: if
  the rules text names menus_to_review while no rule arm is reviewable, or
  if a rule arm becomes reviewable and the text still says there is no
  sample.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:02:32 -04:00
bvandeusenandClaude Opus 5.5 f95ffae972 feat(retrieval): a record the operator names by number reaches the menu (#4796)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 54s
CI & Build / Python tests (push) Successful in 1m47s
CI & Build / Build & push image (push) Successful in 37s
#4796 "A record the operator names by number reaches auto-inject only if
its wording happens to match". "yes go ahead with 4448" says which record
is meant, but a number means nothing to an embedding, so the prompt menu
filled with records resembling the words around it.

- record_refs.named_record_ids reads the operator's raw prompt, never the
  reply-enriched query:
  - `#N`, unless the word before it marks another numbering (PR, CI, rule,
    milestone, system, log...);
  - a bare number of 3 or more digits that opens the message, follows a
    reference word or continues a list one started;
  - never a quantity ("300 seconds"), a date, version, path or fenced code.
- Each id is resolved through the ACL check; trashed or inaccessible ids
  are dropped.
- A "Named in your message" block leads the menu: the kind, the System,
  the name and the opening of the body, or the seen pointer if the record
  is already on the ledger. It takes no share of top_k, and the semantic
  lines leave those ids out.
- The block is booked under the new `named_ref` source, registered as an
  unbidden lookup that is allowed to be quiet. A named id that also ranked
  counts as suppressed in the auto_inject row, so #3668's identity holds.
- Named records now arrive even when the search finds nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 22:10:14 -04:00
bvandeusenandClaude Opus 5.5 5c6d9a7b55 fix(mcp): a tool's refusal reaches the agent with its reason (#4794)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m52s
CI & Build / Build & push image (push) Successful in 27s
SDK 2.x passes on only a ToolError's text. Any other exception becomes
UnexpectedToolError("Error executing tool X") and its message stays on the
server. ValueError is how every Scribe tool refuses — what was refused, why,
what to do instead — so every refusal reached the agent bare, and it could
only retry blind. Seen live: tune_retrieval(actor="operator") and
judge_menu(verdicts=[]) both answered "Error executing tool …" and nothing
else.

StrictArgsMCPServer.call_tool re-raises an UnexpectedToolError caused by a
ValueError as a ToolError carrying the message, in the SDK's own
"Error executing tool X: <reason>" shape. Other exceptions are crashes and
stay masked. The stale comment claiming the SDK returns ValueError text is
corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:31:10 -04:00
bvandeusenandClaude Opus 5.5 df4b37673f fix(plugin): a subagent hand-back is not an operator prompt (#4785)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 54s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / Python tests (push) Successful in 1m49s
CI & Build / Build & push image (push) Successful in 29s
`<agent-message …>` turns were scored by auto-inject and the prompt rule
arm as if the operator had typed them, spending a retrieval and writing a
retrieval_logs row that reads as a real message (log 54355, found by the
#4772 review). Same class as #4142. Matched up to the tag name, since the
tag carries attributes; `<agent-messages>` and the tag named mid-sentence
are still retrieved against.

Plugin 2026.10.04.0125.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:25:37 -04:00
bvandeusenandClaude Opus 5.5 78fa01b025 fix(embeddings): no chunk is a heading alone (#4784)
Judging live auto-inject menus (#4772) found "## Work log — 2026-08-21",
"## Findings worth carrying forward" and "Two dev→main PRs this session."
each embedded as a whole chunk. Text about nothing embeds close to
everything, so they took ranks 1–3 on vague queries and pushed real records
down.

Three ways the chunker made them, each closed:
- _split_paragraphs flushed a heading on its own when the paragraph under it
  was long. A thin lead is now carried into the paragraph that follows, and
  a hard cut never lands in the first quarter of the budget, where the
  newline it finds closes a heading.
- A heading with no body (above its ### parts) became a section of its own
  when the section before it was full. A thin section now takes the next.
- A short lead-in or a short last section stood alone. The lead-in joins
  what follows; a thin tail joins what precedes it.

A continuation piece now repeats the LAST heading before it, since one
section can now hold several. CHUNKER_VERSION 2 → 3, so the startup backfill
re-embeds; tuned dials will report their shape as changed, which is true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 21:25:37 -04:00
bvandeusenandClaude Opus 5.5 5d0e97576b fix(retrieval): the review drops records written after the call it re-runs (#4773)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 36s
The first live sample ranked records the call could never have been offered:
the session that made a call writes the decision, often quoting the message,
and that record tops the re-run. Judged, it inflates on_point exactly where
the budget is decided.

- _rerun over-fetches by POSTDATED_SLACK, drops records created after the
  call before ranking, and names them in `postdated`.
- each line carries `changed_since_call` (updated_at or a work log after
  the call) and `logged` (shown fresh then).
- judge_menu shares the re-run, so a post-dated record cannot be judged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:42:20 -04:00
bvandeusenandClaude Opus 5.5 6598c7fa85 feat(retrieval): a review pass judges whether injected lines related — menus_to_review, judge_menu and a judged readout (#4772)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
An open rate cannot say whether a menu line related: every line carries its
matched passage (#4364), so "not opened" covers unrelated, enough as shown,
and already in context. #4772 "Injected notes are never judged".

- retrieval_judgments (0115): a reviewer verdict per line of a logged call,
  on_point / adjacent / unrelated, with its reason, rank, budget side and
  whether the agent opened it within the hour.
- menus_to_review re-runs a random sample of unjudged auto_inject calls with
  the arm's own parameters, past its budget, passage on every line.
  judge_menu records verdicts, re-deriving rank from a fresh re-run.
- retrieval_telemetry gains a judged block (by rank, within/beyond budget,
  on_point_unopened). surfaced_never_pulled stops blaming titles.
- missed-retrieval guidance names the review before a budget move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:34:14 -04:00
bvandeusenandClaude Opus 5.5 0a1bb68808 feat(rulings): system_usage_events is read back — per-System counts and a telemetry block (#4769)
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 36s
#4769 "Rulings are counted where someone will read them": milestone 444
step 4 wrote system_usage_events and nothing read it.

- retrieval_telemetry gains a `system_usage` block: surfacings and opens by
  source, distinct counts, and `by_system` naming the areas most shown.
  There is deliberately no pull-through ratio, because rulings travel in full
  in the line and opens are the exception.
- usage_for_systems (one GROUP BY) adds `usage` to the REST Systems list and
  detail, and to MCP get_system. MCP list_systems is unchanged.
- The Systems UI shows a "rulings shown N×" chip.
- rulings_pre_tool, rulings_write_path and mcp_get_system are now declared
  registry points; the registry guard covers their recorders.
- The Systems store merges a PATCH reply instead of replacing the row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:49:20 -04:00
bvandeusenandClaude Opus 5.5 1b973ebd13 fix(write-path): a search hit's excerpt under snippet no longer 500s the prior-art route (#4768)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m5s
CI & Build / Python tests (push) Successful in 1m50s
CI & Build / Build & push image (push) Successful in 39s
#4768 "Write-path prior-art route 500s when a nameless search hit carries
its excerpt under `snippet`": _prior_art_line read item["snippet"] as a
snippet record's field dict, but a search hit carries its matched passage
there as a string. Read the name only from a dict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 08:39:11 -04:00
bvandeusenandClaude Opus 5.5 891f375715 test(rulings): the hook/route parity guards pin the rulings params (#4757)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m49s
CI & Build / Build & push image (push) Successful in 33s
CI 7939 went red on test_the_hook_and_the_route_agree_on_every_parameter_name:
the Bash hook now sends root and cwd, and the guard pins the exact set. Both
guards (tool-rules and prior-art) now also pin seen_ruling_systems, spelled
once in scribe_rulings_query, and check the routes read all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:12:40 -04:00
bvandeusenandClaude Opus 5.5 556872c039 feat(rulings): a command or edit touching an area's files shows its rulings, once per session (milestone 444 step 4, #4757)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m6s
CI & Build / Python tests (push) Failing after 1m22s
CI & Build / Build & push image (push) Skipped
A System's rulings (the Rulings section of its description) now reach the
work by path, not by similarity. Both PreToolUse arms resolve the files a
command or edit names to the Systems whose path_patterns cover them, and the
first touch in a session shows each area's rulings in one line; a repeat is
a one-line reference. A lookup, so no floor, no budget, no retrieval_logs row.

- services/system_rulings: parse_rulings, command_paths (reads and writes,
  relative to the repo root from any cwd; flags, URLs, globs skipped),
  rulings_for_paths
- /tool-rules takes root, cwd and seen_ruling_systems; /prior-art takes
  seen_ruling_systems; both return ruling_system_ids
- hooks share <sid>.rulings.ids (cleared on compaction by the ledger naming
  convention); the Bash hook sends the repo root and cwd
- system_usage_events (migration 0114): surfacings by source, pulls from
  get_system; carried by backup (v20) through the system map
- writing-records: rulings also arrive when the area's files are touched

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:08:32 -04:00
bvandeusenandClaude Opus 5.5 a113c72b4f feat(systems): a System names its files — path patterns stored, validated and matched (milestone 444 step 3, #4756)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m6s
CI & Build / Python tests (push) Successful in 1m50s
CI & Build / Build & push image (push) Successful in 43s
A System gains path_patterns: globs relative to the repo root (* within one
directory, ** across any depth, a plain directory covering everything under
it). One service validates them for every door, so the web UI and MCP refuse
the same bad pattern with the same message. systems_for_paths resolves paths
to every active System that covers them, which step 4 (#4757) uses to deliver
an area's rulings when its files are touched.

- schema: systems.path_patterns JSONB NOT NULL default [] (migration 0113)
- service: normalize_path_patterns, path_matches, systems_for_paths
- routes + MCP create_system/update_system accept it; [] clears
- web UI: a Files field in the create and edit forms, patterns on the card
- backup carries it through export and restore
- using-scribe reflex 7: tagging work keeps a System's files current

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:55:26 -04:00
bvandeusenandClaude Opus 5.5 01d8a0b9f1 feat(guidance): an operator's ruling lives on the System it governs, and code is read as behaviour, not intent (milestone 444 steps 1-2, #4754 #4755)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m46s
CI & Build / Build & push image (push) Successful in 56s
A Librarian session contradicted a decision the operator had made 13 days
earlier. The ruling ("retry, then replace, never give up on a book") was
kept only as a quote in a work log, beside a session's reading of it that
capped replacements at 3. Three later sessions built on the reading, and one
carried the cap into an option as a "known cost", which the operator then
approved without being asked about it.

- writing-records.md: "A ruling goes on the System it governs". What a
  ruling is (the operator decided it; a later change could undo it), how it
  differs from a rule, and where it goes: a Rulings section at the end of
  the System description, one line each with who, when and the source
  record. Written the turn the operator decides; holds what is in force,
  not history; a charter line that contradicts a ruling is fixed in the
  same edit.
- using-scribe SKILL.md: reflex 1 says code tells you what a thing does,
  not what was wanted, and a limit read from code is unconfirmed until a
  System's Rulings says otherwise. Reflex 9 points to the ruling section.
- reporting-back: an option that carries existing behaviour says whose call
  it was (the operator's ruling, or a past session's never confirmed); one
  that contradicts a ruling is a Conflict.
- create_system / update_system docstrings: the Rulings section, and that
  description replaces the whole text.
- test_guidance_ownership: three topics pinned to their owners.
- Plugin version minted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 18:59:36 -04:00
bvandeusenandClaude Opus 5.5 cf4206469b feat(lessons): the web editor records which rule a lesson is an instance of (milestone 440, #4658)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 59s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 37s
The lesson editor now asks the question the create tool asks, while the
writer still has the situation in mind. It offers three answers:
- an instance of a rule, ticked from the rules the lesson resembles;
- "No rule fits", with a required reason;
- leave it open.

The rules are fetched before the save from a new GET
/api/lessons/rule-candidates. It runs the same rule_candidates search the
create door replies with, and passes "search unavailable" through as null,
apart from "nothing resembles it".

An edit sends an answer only when it changed. Re-sending the same rules
would re-stamp their judgments. Worse, a shared editor who cannot see the
owner's rule would reject it by sending a list without it. Leaving a linked
lesson open sends rule_ids=[], which is what clears the links.

"Leave it open" is not offered once "no rule fits" is on file: the server
has no way to take that answer back except by naming a rule. When "no rule
fits" completes a convergence group, the editor says so in a toast, since
the page it goes to does not recompute it.

Guards check that the payload uses the names both routes read, that the
three answers are offered, that the candidates route sits above the id route
and keeps null apart from [], and that the editor reuses .rule-chip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:36:43 -04:00
742 changed files with 50039 additions and 22995 deletions
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "scribe-plugin",
"name": "grimoire-plugin",
"owner": { "name": "Bryan Van Deusen" },
"description": "Scribe ships its own Claude Code plugin from this repo, versioned in lockstep with the app + the /api/plugin/context contract.",
"description": "Grimoire ships its own Claude Code plugin from this repo, versioned in lockstep with the app + the /api/plugin/context contract.",
"plugins": [
{
"name": "scribe",
"name": "grimoire",
"source": "./plugin",
"description": "Scribe second brain: MCP tools + session-start push channel + universal process-skills."
"description": "Grimoire second brain: MCP tools + session-start push channel + universal process-skills."
}
]
}
+7 -2
View File
@@ -1,4 +1,9 @@
# An install created before the rename from Scribe keeps its database by
# setting these, and PGDATA_VOLUME, to the names it was created with.
POSTGRES_USER=fabled
POSTGRES_PASSWORD=fabled
POSTGRES_DB=scribe
SECRET_KEY=dev-secret-change-me
POSTGRES_DB=grimoire
# PGDATA_VOLUME=grimoire_pgdata
# Grimoire keeps its own SECRET_KEY in the /data volume. Set one here only to
# use a key you manage yourself; it then wins over the kept one.
# SECRET_KEY=
+88 -186
View File
@@ -4,27 +4,15 @@
# Push to main: typecheck + lint + test + build :latest + :<sha>
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
#
# Both dev and main are gated AND built. dev pushes move :dev; main pushes move
# :latest — main IS the production line, so :latest tracks main's tip and there
# is no separate :main tag. Every push also gets an immutable :<sha> (the
# rollback point). A v* release tag additionally publishes the dated :<version>;
# since main already moved :latest, the release tag's distinct job is that
# :<version> marker (it refreshes :latest too, harmlessly).
# main is the production line, so :latest tracks main's tip. Every push also
# gets an immutable :<sha>, the rollback point. A release (a v* tag created on
# main in the Forgejo UI) adds the dated :<version>.
#
# Successive pushes to the SAME ref supersede each other (see concurrency
# below), so rapid pushes don't stack identical work; dev and main runs are
# independent refs and never cancel one another.
# A newer push to the same ref cancels the older run; dev and main never cancel
# each other. Pull requests don't trigger runs: branch pushes already gate.
#
# To cut a release:
# Create a release via the Forgejo UI on main with a v* tag name.
# The tag push triggers this workflow; build job pushes :latest + :<version>.
#
# PRs aren't triggered on purpose — this is a solo dev→main flow, so
# gating on branch push is already enough.
#
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
# honor `on.push.branches` as a filter, so every job repeats the ref check
# explicitly — permitting dev, main, and v* tags, rejecting anything else.
# Forgejo Actions doesn't reliably honour `on.push.branches` as a filter, so
# every job repeats the ref check in its `if:`.
#
# Required secrets (repo → Settings → Secrets → Actions):
# REGISTRY_USER — your Forgejo username
@@ -40,31 +28,22 @@ on:
- "frontend/**"
- "tests/**"
- "pyproject.toml"
# The lock now determines what gets installed, so a lock-only change has
# to trigger a run — otherwise a dependency bump lands untested.
# The lock decides what is installed, so a lock-only change must run CI.
- "uv.lock"
- "alembic/**"
- "alembic.ini"
- "Dockerfile"
# The plugin ships straight from this repo — installs fetch it via
# .claude-plugin/marketplace.json, NOT from the image. So a push here is
# the release, with no build step in between. Omitting these paths meant
# plugin changes triggered no workflow at all, which is how #2198's three
# broken hooks and then #2209's missing version bump both reached a live
# install. See the `plugin` job below.
# The plugin ships straight from git (via .claude-plugin/marketplace.json),
# not in the image, so a push here is its release. See the `plugin` job.
- "plugin/**"
- ".claude-plugin/**"
- "scripts/check_plugin.py"
- ".forgejo/workflows/ci.yml"
# Manual trigger from the Forgejo Actions UI. Useful when an image has
# been built but the deployment didn't pick it up, or when re-running
# against the same source produces different upstream behaviour
# (e.g. a transient HF download flake during the voice-bundle step).
# Manual run from the Forgejo Actions UI, e.g. to rebuild an image.
workflow_dispatch: {}
# Cancel older runs on the same branch when a newer push lands. Tag runs
# get their own group implicitly (refs/tags/v1.2.3 ≠ refs/heads/dev) and
# are never cancelled, so a release build can't kill itself mid-flight.
# A newer push cancels the older run on the same branch. Tag runs are never
# cancelled, so a release build can't be cut off mid-flight.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
@@ -76,7 +55,7 @@ permissions:
env:
REGISTRY: git.fabledsword.com
IMAGE: git.fabledsword.com/bvandeusen/fabledscribe
IMAGE: git.fabledsword.com/bvandeusen/fabledgrimoire
jobs:
typecheck:
@@ -91,10 +70,7 @@ jobs:
- name: Cache npm download cache
uses: actions/cache@v4
# Non-fatal: a transient cache-backend hiccup must NOT fail the whole
# typecheck job (it was skipping install + type check and reporting red
# on backend-only pushes — see issue task #828). On cache miss/error the
# job just installs without the cache.
# Non-fatal: a cache-backend error just means installing without it.
continue-on-error: true
with:
path: ~/.npm
@@ -110,8 +86,7 @@ jobs:
working-directory: frontend
# Guards the one part of this repo that ships to users without a build step.
# See scripts/check_plugin.py for what it checks and, as importantly, what it
# can't check yet (shellcheck is absent from ci-python).
# scripts/check_plugin.py says what it checks and what it can't.
plugin:
name: Plugin hooks
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
@@ -119,25 +94,14 @@ jobs:
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
# Bare `uses:`, no `with:` block. Adding one made this action fail to
# extract on the act_runner ("Cannot find module .../dist/index.js") while
# every bare checkout in the same run succeeded — see run 3027. Nothing
# here needs `fetch-depth: 0` anyway: the version check diffs two trees,
# and a tree diff needs both trees, not a common ancestor. A depth-1 fetch
# of main's tip is enough, and cheaper.
# Keep this a bare `uses:`: with a `with:` block the action fails to
# extract on act_runner ("Cannot find module .../dist/index.js"). The
# version check diffs two trees, so a depth-1 fetch of main is enough.
- uses: actions/checkout@v6
# Per-job, not in the image, per CI-runner's docs/process.md: "If only one
# project needs the dep, prefer that project installing it per-job in
# their workflow — at least until a second consumer arrives." Scribe is
# the only consumer today. Promotion into ci-python is filed as an issue
# on CI-runner rather than assumed here.
#
# jq WAS installed here too, because every hook exited at line 1 without
# it and the smoke test would have passed while exercising nothing. The
# hooks need no jq since #4107 — they use only what POSIX guarantees — so
# the smoke test now runs on a bare image, which is the condition it is
# actually meant to be asserting.
# Per-job rather than in the image: Grimoire is shellcheck's only consumer.
# Install nothing else here; the hooks use only POSIX tools, and the
# smoke test runs on a bare image to prove it.
- name: Install shell tooling
run: |
apt-get update -qq
@@ -163,28 +127,19 @@ jobs:
steps:
- uses: actions/checkout@v6
# ruff is pre-installed in the ci-python image — no install
# step needed, lint runs in ~2s.
# ruff is pre-installed in the ci-python image.
- name: Lint
run: ruff check src/ scripts/
# Design tokens: does the frontend's CSS agree with the stylesheet the
# design system generates? Fails only on an unresolvable var() reference —
# that count is at zero, so this is a ratchet rather than a backlog. The
# literal findings are printed, not gated; hundreds exist and a
# permanently-red job is one nobody reads.
#
# Stdlib only, no install, no network: the source of truth is theme.css,
# which is generated from the design system and committed.
# Checks the frontend's CSS against the committed, generated theme.css.
# Fails only on an unresolvable var() reference; hard-coded literals are
# reported, not gated.
- name: Design token check
run: python3 scripts/check_design_tokens.py --report-literals
# Dangling styles: an element whose classes have only modifier rules and
# no base — a deleted CSS rule that left its `:hover` behind. Two shipped
# this way (a link rendering as raw browser blue, a flex row whose parent
# was gone so every child stacked). Neither is visible to vue-tsc; a dead
# style typechecks perfectly. Reported, not gated — a bare wrapper is
# legitimate, so the signal is the count growing.
# Reports elements whose classes have modifier rules but no base rule,
# which vue-tsc can't see. Not gated: a bare wrapper is legitimate, so
# the signal is the count growing.
- name: Dangling style check
run: python3 scripts/check_dangling_styles.py
@@ -201,94 +156,66 @@ jobs:
uses: actions/cache@v4
with:
path: ~/.cache/uv
# Keyed on the LOCK, not pyproject: the lock is what determines the
# installed set now, and a pyproject edit that doesn't change
# resolution shouldn't throw the cache away.
# Keyed on the lock, which decides the installed set.
key: uv-${{ hashFiles('uv.lock') }}
restore-keys: uv-
# Installs exactly what uv.lock pins, and resolves nothing itself.
#
# This replaced `uv pip install -e ".[dev]"`, which resolved from the
# pyproject constraints and ignored the lock entirely. Every dependency
# floated: on 2026-07-28 mcp 2.0.0 shipped mid-session and turned `main`
# red with no repo change (issue #2194). Green CI has to mean "these exact
# versions passed", or it isn't evidence of anything.
#
# `--locked` also FAILS when uv.lock is stale against pyproject, so a
# dependency edit has to go through a deliberate `uv lock` — it can't
# arrive on its own. That check earned its place immediately: it caught
# that the lock had been missing `pgvector` entirely (added to pyproject,
# never re-locked), which the old install path had been silently papering
# over by resolving from pyproject instead.
# Installs exactly what uv.lock pins, so green means these exact versions
# passed. `--locked` fails if the lock is stale against pyproject, so a
# dependency change needs a deliberate `uv lock`.
- name: Install locked dependencies
env:
UV_PROJECT_ENVIRONMENT: /opt/venv
run: uv sync --locked --extra dev
# An "Install jq for hook execution tests" step stood here. The hook
# EXECUTION tests run the real bash hook, which exited silently without
# jq and skipped rather than failed, so the step existed to stop them
# being silently unverified. Since #4107 the hooks need no jq, so the
# step is gone and those tests run against a bare image — and the point
# of removing a dependency is lost if CI keeps installing it anyway.
# The hook execution tests run on a bare image (no jq), like a user's
# machine might be.
- name: Run tests
# Integration tests (real Postgres) run in the `integration` job below.
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
# Real-Postgres lane (family rule 6). Exercises the async SQLAlchemy connection
# path the unit stubs can't reach — the un-awaited execution_options regression
# that made every VACUUM report 0/6 lived here. Like `test`, it runs for
# visibility and does NOT gate the build.
# Real Postgres, built by the real migrations: covers the async SQLAlchemy
# paths unit stubs can't reach.
#
# Job key stays separator-free ("integration"): act_runner derives the service-
# container name from the (truncated) job display name and the discovery step
# filters `docker ps` by it. Service hostnames aren't routable on this runner,
# so the step resolves the Postgres container's bridge IP. No `name:` on purpose.
# Service hostnames aren't routable on this runner, so the step finds the
# Postgres container's bridge IP. The job has no `name:` and a separator-free
# key because act_runner names service containers after it.
integration:
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
env:
# Config + the module engine read these at import time. DATABASE_URL itself
# is built from the discovered service IP in the run step.
# Read at import time. DATABASE_URL is built from the discovered IP below.
SECRET_KEY: ci_integration_placeholder
services:
postgres:
# pgvector image so `alembic upgrade head` can run migration 0067
# (CREATE EXTENSION vector). PG17 — matches the prod/quickstart image.
# Same image as the shipped compose files: Postgres 17 with pgvector.
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: scribe
POSTGRES_USER: grimoire
POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: scribe_test
POSTGRES_DB: grimoire_test
options: >-
--health-cmd "pg_isready -U scribe"
--health-cmd "pg_isready -U grimoire"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v6
# Same locked install as the unit lane — the two must agree on versions,
# or "unit green, integration red" stops being a signal about the code.
# Same locked install as the unit lane, so both test the same versions.
- name: Install locked dependencies
env:
UV_PROJECT_ENVIRONMENT: /opt/venv
run: uv sync --locked --extra dev
# Standing answers to the checks carried by rules 81 and 79 — two facts
# about THIS runner that conditional rules assert as fact, and that
# otherwise need a throwaway job to confirm (#3237). Printing them on
# every integration run makes the next rulebook sweep a log read.
# Rule 80's evidence is the container listing the next step already
# prints. Every command is guarded: a diagnostic that can break the lane
# it observes is worse than no diagnostic.
- name: Runner facts (rules 79 and 81)
# Prints two facts about the runner that the steps below depend on.
# Every command is guarded so the diagnostic can't fail the lane.
- name: Runner facts (shell, service hostnames)
run: |
echo "--- rule 81: which shell runs a run: step ---"
echo "--- which shell runs a run: step ---"
readlink -f /bin/sh || echo "/bin/sh: not a symlink"
ps -p $$ -o comm= || true
echo "--- rule 79: is a service reachable by its hostname yet? ---"
echo "--- is a service reachable by its hostname? ---"
getent hosts postgres \
|| echo "no — 'postgres' does not resolve; the bridge-IP lookup is still required"
- name: Integration suite (resolve service IP, migrate, test)
@@ -296,14 +223,20 @@ jobs:
set -eux
echo "=== container landscape (diagnostic for the name filter) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg17" -q | head -n1)
test -n "$PG"
# Only this job's services: the docker daemon is shared, so a job-name
# filter can match another repo's Postgres. act names a task's containers
# GITEA-ACTIONS-TASK-<n>-...; read <n> from our own container (its id is in
# the /etc/hostname bind mount) and require exactly one match.
SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true)
SELF=${SELF:-$(hostname)}
TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-')
test -n "$TASK"
PG=$(docker ps --filter "name=$TASK" --filter "ancestor=pgvector/pgvector:pg17" -q)
test "$(printf '%s\n' "$PG" | grep -c .)" -eq 1
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP"
export DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test"
# Wait for Postgres to accept connections. The run: shell is dash
# (/bin/sh -> /usr/bin/dash on this Debian-based image, confirmed by
# the step above) — no bash /dev/tcp, so use Python.
export DATABASE_URL="postgresql+asyncpg://grimoire:ci_integration@${PG_IP}:5432/grimoire_test"
# Wait for Postgres. The run: shell is dash (no bash /dev/tcp), so use Python.
/opt/venv/bin/python - "$PG_IP" <<'PY'
import socket, sys, time
for _ in range(30):
@@ -322,15 +255,12 @@ jobs:
build:
name: Build & push image
# `plugin` is deliberately NOT in needs. The plugin isn't in the image —
# installs fetch it from git — so gating the server image on a hook lint
# would couple two things that don't ship together, and blocking the build
# wouldn't un-publish a bad hook anyway: the push already did that. A failed
# `plugin` job still turns the whole run red, which is the signal that
# matters.
needs: [typecheck, lint, test]
# Build on dev, main, and v* tag pushes. dev → :dev, main → :latest,
# tag → :latest + :<version>; every build also gets an immutable :<sha>.
# `plugin` is not in needs: the plugin ships from git, not in the image, so
# holding the image back wouldn't stop a bad hook. A red `plugin` job still
# turns the run red. `integration` is: the image carries the migrations and
# queries that lane tests.
needs: [typecheck, lint, test, integration]
# dev → :dev, main → :latest, tag → :latest + :<version>; all get :<sha>.
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
@@ -341,53 +271,37 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
# Rule 149 asks for this on any job deriving the version NAME. The
# name here comes from HEAD's commit TIME, which a depth-1 clone
# already has — but the rule states it unconditionally because the
# failure it guards is silent (a too-low value, every lane green),
# and a later change to how the name is derived would inherit the
# landmine rather than the guard.
# Full history for the version derivation. Today it needs only HEAD's
# commit time, but a shallow clone fails silently (a too-low value) if
# the derivation ever reads further back.
fetch-depth: 0
- name: Generate image tags and version
id: tags
# POSIX `case` instead of bash `[[ ]]` because act_runner invokes
# `sh -e` (dash on the ci-python:3.14 image, which has no bash on
# the default PATH for /bin/sh). Previous `[[ ]]` form failed
# silently — only the SHA tag got appended, so :dev / :latest
# never updated in the registry and the deployed stack kept
# pulling stale images. Verified via `[[: not found` lines in
# the runner log on commit 2a374d9.
# POSIX `case`, not bash `[[ ]]`: the run: shell is dash, where `[[`
# fails silently and only the :<sha> tag would be pushed.
run: |
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
# THREE VALUES, NEVER FOLDED TOGETHER (rule 149). Until 2026-08-31
# BUILD_VERSION was the CHANNEL — "dev" / "main" / the tag — so the
# image self-reported {"version":"main"}, a channel name where a
# build identifier belongs. That cost a debugging session: with the
# deploy misbehaving, nothing on the running instance could say
# which commit was serving it.
# Three values kept separate; the Dockerfile describes each.
# 1. ORDERING KEY — BUILD time, monotonic by construction. Minutes
# since 2020-01-01. Never a commit count (not monotonic across
# branches) and never commit time (goes DOWN when an older
# commit is rebuilt).
# 1. Ordering key: build time in minutes since 2020-01-01, so it only
# goes up. Commit time would go down when an older commit is
# rebuilt; a commit count isn't monotonic across branches.
BUILD_KEY=$(( ( $(date -u +%s) - 1577836800 ) / 60 ))
# 2. NAME — COMMIT time, so the same source reports the same string
# on every lane and the channel is the only thing that differs.
# 2. Name: commit time, so the same source reads the same on every lane.
COMMIT_TS=$(git log --format=%ct -1 HEAD)
BUILD_NAME=$(date -u -d "@$COMMIT_TS" +%Y.%m.%d.%H%M)
# 3. CHANNEL — its own value. Never a suffix, never a segment.
# 3. Channel: its own value, never part of the name.
CHANNEL="dev"
case "${{ github.ref }}" in
refs/heads/dev)
TAGS="$TAGS,${{ env.IMAGE }}:dev"
;;
refs/heads/main)
# main IS the production line: publish :latest (plus the :<sha>
# set above). No separate :main tag.
# main is the production line, so it publishes :latest.
TAGS="$TAGS,${{ env.IMAGE }}:latest"
CHANNEL="stable"
;;
@@ -401,16 +315,10 @@ jobs:
echo "build_key=$BUILD_KEY" >> $GITHUB_OUTPUT
echo "channel=$CHANNEL" >> $GITHUB_OUTPUT
- name: Free disk space
# Self-hosted runner housekeeping. Two-step cleanup:
# 1. Prune dangling containers/images globally (stops the runner
# from accumulating cruft from past failed builds).
# 2. Trim the BuildKit layer cache to a 5GB ceiling so the pip
# mount cache survives but old intermediate layers don't
# accumulate indefinitely.
run: |
docker system prune -af || true
docker builder prune --keep-storage 5g -f || true
# No disk-space prune step: the runner's Docker daemon is shared, and
# `docker system prune -af` here deleted images other repos' jobs had
# just pulled and were about to start (CI-runner #16). The runner host
# prunes nightly instead.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -429,19 +337,13 @@ jobs:
push: true
provenance: false
tags: ${{ steps.tags.outputs.value }}
# All three, plus the commit — rule 145: the registry's identity for
# a build (:<sha>) and the artifact's identity for itself must
# agree, and they can only be checked against each other if the
# artifact says which commit it is.
# The commit lets the image's self-report be checked against :<sha>.
build-args: |
BUILD_VERSION=${{ steps.tags.outputs.build_name }}
BUILD_KEY=${{ steps.tags.outputs.build_key }}
BUILD_CHANNEL=${{ steps.tags.outputs.channel }}
BUILD_COMMIT=${{ github.sha }}
# Registry-backed layer cache. Pull from :cache to prime
# BuildKit, push updated layers back to :cache so the next
# build starts warm even if the runner's local cache was
# pruned. `mode=max` exports all intermediate layers, not
# just the final image, which is what gives the ~80% speedup.
# Layer cache kept in the registry, so a build starts warm even after
# the runner prunes. `mode=max` caches intermediate layers too.
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true
+17 -22
View File
@@ -12,11 +12,8 @@ RUN npm run build
FROM python:3.14-slim AS runtime
WORKDIR /app
# Installed from uv.lock, exactly like CI (issue #2194). This used to be
# `COPY pyproject.toml .` + `pip install .`, which never even copied the lock:
# the shipped image resolved its own dependency set, so CI could be green on one
# set of versions while the published image ran another. On 2026-07-28 that
# class of drift turned `main` red when mcp 2.0.0 shipped mid-session.
# Installed from uv.lock, exactly like CI, so the image runs the dependency
# versions CI tested.
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir uv
@@ -34,28 +31,22 @@ RUN --mount=type=cache,target=/root/.cache/uv \
# alembic and hypercorn in CMD have to be found there.
ENV PATH="/app/.venv/bin:$PATH"
COPY --from=build-frontend /build/dist/ src/scribe/static/
COPY --from=build-frontend /build/dist/ src/grimoire/static/
COPY alembic.ini .
COPY alembic/ alembic/
# Ensure Python finds the source tree (where static files live) before site-packages
ENV PYTHONPATH=/app/src
# THREE VALUES, NEVER FOLDED TOGETHER (rule 149), plus the commit.
#
# BUILD_VERSION is the NAME (YYYY.MM.DD.HHMM, from COMMIT time) — the same
# string on every lane for the same source, so it answers "is this the same
# code?" rather than "which lane built it?".
# BUILD_KEY is the ORDERING KEY (minutes since 2020-01-01, from BUILD time) —
# the only value anything may compare to decide what is newer.
# BUILD_CHANNEL is its own field. Never a suffix, never a segment of the name.
# BUILD_COMMIT lets the artifact's self-report be checked against the :<sha>
# it was published under (rule 145).
#
# Each defaults to empty rather than to a placeholder, EXCEPT the name: a
# local build genuinely has no ordering key or channel, and the endpoint says
# so by omitting them. Inventing values would make a local image claim a
# position in an update order it is not part of.
# Three values kept separate, plus the commit:
# BUILD_VERSION is the name (YYYY.MM.DD.HHMM from commit time), the same on
# every lane for the same source.
# BUILD_KEY is the ordering key (minutes since 2020-01-01 from build time), the
# only value compared to decide which build is newer.
# BUILD_CHANNEL is its own field, never part of the name.
# BUILD_COMMIT lets the image's self-report be checked against its :<sha> tag.
# A local build leaves the key and channel empty: it has no place in an update
# order, and /api/version omits them.
ARG BUILD_VERSION=dev
ARG BUILD_KEY=
ARG BUILD_CHANNEL=
@@ -65,5 +56,9 @@ ENV APP_BUILD_KEY=$BUILD_KEY
ENV APP_CHANNEL=$BUILD_CHANNEL
ENV APP_COMMIT=$BUILD_COMMIT
# The instance's own SECRET_KEY, the model cache and diagnostics live here.
# Mount a volume on it so they outlive the container.
RUN mkdir -p /data
EXPOSE 5000
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'grimoire.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+9 -10
View File
@@ -3,24 +3,24 @@
# --- Docker ---
build:
docker compose build
docker compose -f docker-compose.dev.yml build
up:
docker compose up -d
docker compose -f docker-compose.dev.yml up -d
down:
docker compose down
docker compose -f docker-compose.dev.yml down
logs:
docker compose logs -f
docker compose -f docker-compose.dev.yml logs -f
health:
curl -s http://localhost:5000/api/health
migrate:
docker compose exec app alembic upgrade head
docker compose -f docker-compose.dev.yml exec app alembic upgrade head
# --- Local dev (requires: pip install -e ".[dev]" and cd frontend && npm ci) ---
# --- Local dev (requires: uv sync --extra dev and cd frontend && npm ci) ---
lint:
ruff check src/
@@ -39,9 +39,8 @@ check: lint typecheck test
# --- Plugin ---
# Run this after changing anything under plugin/ or .claude-plugin/, BEFORE
# committing. The plugin ships straight from git with no build step, so its
# version is minted here rather than stamped by CI; the lane fails if you
# forget, but this is what makes remembering cheap.
# Run after changing plugin/ or .claude-plugin/, before committing: the plugin
# ships from git with no build step, so its version is minted here. CI fails if
# the version wasn't bumped.
mint-plugin:
python3 scripts/mint_plugin_version.py
+137 -27
View File
@@ -1,40 +1,150 @@
# Fabled Scribe
<p align="center">
<img src="frontend/public/favicon.svg" width="96" alt="">
</p>
A self-hosted work system-of-record for software projects, built to be driven by Claude Code. Notes, tasks, issues, projects, milestones, rules, and stored processes — reachable from Claude via a built-in MCP endpoint and a bundled Claude Code plugin, with a clean web UI for humans. No in-app LLM; Claude is the sole assistant.
<h1 align="center">Fabled Grimoire</h1>
<p align="center">A self-hosted system of record for the work you do with Claude Code.</p>
Claude records what it learned, decided and built in Grimoire, and that record reaches the next session, so a solution is recalled instead of reinvented. Notes, tasks, plans, rules, lessons and reusable code live in one place. Claude reaches them through a built-in MCP endpoint and the bundled Claude Code plugin, and people read and edit the same records in the web UI. There is no language model inside Grimoire: Claude is the only assistant.
## Deploy
You need Docker with Compose. Save this as `docker-compose.yml` (it is the [`docker-compose.yml`](docker-compose.yml) in this repository), then run `docker compose up -d` beside it:
```yaml
# Fabled Grimoire: the app and its database.
# docker compose up -d
# Then open http://localhost:5000. The first account registered becomes admin.
#
# Keep both volumes. app_data holds the instance's own SECRET_KEY, which seals
# saved credentials; pgdata holds everything else.
services:
app:
image: git.fabledsword.com/bvandeusen/fabledgrimoire:latest
ports:
- "5000:5000"
environment:
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-grimoire}:${POSTGRES_PASSWORD:-grimoire}@db:5432/${POSTGRES_DB:-grimoire}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
# Port 5000 is published straight to clients, so no proxy is trusted.
# Behind a reverse proxy, set 1 (one per proxy) and BASE_URL:
TRUSTED_PROXY_HOPS: "0"
# BASE_URL: "https://grimoire.example.com"
volumes:
- app_data:/data
depends_on:
- db
restart: unless-stopped
db:
# Postgres 17 with the vector extension Grimoire needs.
image: pgvector/pgvector:pg17
stop_grace_period: 120s
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: ${POSTGRES_USER:-grimoire}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-grimoire}
POSTGRES_DB: ${POSTGRES_DB:-grimoire}
restart: unless-stopped
volumes:
app_data:
pgdata:
```
Open `http://localhost:5000`. The first account you register becomes the admin.
Nothing has to be set first. On its first start Grimoire makes its own `SECRET_KEY` and keeps it in the `app_data` volume, so restarts, reboots and updates all use the same key.
To connect Claude, create an API key under **Settings → MCP Access**, then in Claude Code:
```
/plugin marketplace add https://git.fabledsword.com/bvandeusen/FabledGrimoire.git
/plugin install grimoire@grimoire-plugin
```
The plugin asks for your Grimoire URL and the API key. Settings → MCP Access shows the exact commands for your instance.
## Configuration
Everything is optional. Set these in the environment or in a `.env` file beside the compose file:
| Variable | When | What it does |
|----------|------|--------------|
| `BASE_URL` | Behind a proxy | The public URL, used in email links and SSO redirects |
| `TRUSTED_PROXY_HOPS` | Rarely | The starting count of reverse proxies in front of Grimoire (default `1`; the compose file above sets `0` because it publishes the port directly). Check and change it in **Settings → Config → Reverse proxy**, which shows what each count reads for your own request; behind Cloudflare it is usually `2` |
| `POSTGRES_PASSWORD` | Before the first start | The database password (defaults to `grimoire`; the database is reachable only inside the compose network). Postgres takes it once, when the volume is created |
| `SECRET_KEY` | Rarely | A key you manage yourself, instead of the one Grimoire keeps. It wins over the kept key; remove it to go back |
Everything else (registration, email, SSO, git forge connections, retrieval tuning, backups) is in **Settings**, or listed in [Configuration](docs/configuration.md).
## Setup pitfalls
- **Keep both volumes.** `pgdata` holds everything Grimoire stores. `app_data` holds the instance's key, and forge tokens and the backup bucket's secret are sealed under it. If the key is lost, Settings asks you to enter those credentials again; nothing else is lost.
- **The embedding model downloads on first use**, into the `app_data` volume. Without internet access, semantic search falls back to keyword search until the model is in place.
- **Behind a reverse proxy, turn off response buffering** for `/mcp`, because MCP responses stream. [Configuration](docs/configuration.md#behind-a-reverse-proxy) has an Nginx example.
## Updating and backups
```bash
docker compose pull
docker compose up -d
```
Database migrations run when the app starts, and the key stays in its volume. Claude Code updates the plugin from its marketplace; run `/reload-plugins` after an update.
**Settings → Data → Scheduled backups** writes a daily backup of the database to a folder or an S3-compatible bucket, and Settings → Data → Restore from Backup rolls the instance back to one, or brings a new instance back from it. See [Backups](docs/backups.md).
## Features
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system (with an inception step that decides what each project inherits), and semantic search with proactive knowledge-injection into Claude's context. A knowledge graph, per-user/group sharing, and a built-in MCP server (`/mcp`) plus a bundled Claude Code plugin so Claude can record and recall your work directly.
**Working with Claude**
- Claude enters a project and gets a short primer: its goal, current plans, open tasks and areas.
- Your rules reach Claude when its work matches them, or at a moment of work such as finishing a task or pushing code.
- Before Claude writes a file or runs a command, the plugin shows it the rules, lessons and reusable code that may apply.
- Stored procedures, such as a drift audit, become Claude Code skills.
## Quick Start
**Recording the work**
- Notes in a Markdown editor, with wikilinks, tags and version history.
- Tasks, issues and time-boxed investigations, with work logs that the next session reads first.
- Plans as milestones: a design plus ordered steps.
- Lessons learned the hard way, linked to the rules they illustrate.
- Duplicate checks, so a new record that matches an existing one updates it instead.
**Prerequisites:** Docker and Docker Compose. No GPU or local model needed — Claude is the sole assistant, reached over MCP.
**Reusing code**
- Snippets of reusable code, each with when to reach for it and where it lives in the repo.
- With a git forge connected, Grimoire tracks every function and style in your repos and shows which ones are still unaccounted for.
- Ideas shared across projects on the same platform, with each project's answer recorded.
- Design systems with tokens, inheritance and a generated stylesheet.
Download [`docker-compose.quickstart.yml`](docker-compose.quickstart.yml) from this repo, then:
**For people**
- A web UI to browse, search and edit everything, plus a graph of how notes connect.
- Sharing with users and groups, as viewer, editor or admin.
- Email and password sign-in, optional SSO through OIDC, and invite links.
- Personal export to Markdown or JSON, and admin backup and restore.
The full list is in [Features](docs/features.md).
## Troubleshooting
| What you see | Why | Fix |
|--------------|-----|-----|
| `SECRET_KEY is set to the public placeholder` at startup | `SECRET_KEY` is `dev-secret-change-me` or `change-me-in-production` | Remove `SECRET_KEY`, or set a real one |
| Settings asks for forge tokens or the backup secret again | The instance's key changed: the `app_data` volume was replaced, or `SECRET_KEY` was set or removed | Enter them again, or restore the previous key |
| `Can't locate revision identified by '…'` at startup | An older image is running against a database a newer one has already migrated | Run the newer image; migrations only move forward |
| The plugin's tools are missing in Claude Code | The plugin isn't installed, or Claude Code hasn't reloaded it | `/plugin` to install or update it, then `/reload-plugins` |
| Search finds only exact words | The embedding model hasn't downloaded yet | Give the app internet access once, or wait for the first download to finish |
## Development
```bash
# Optional but recommended — set a secret key
export SECRET_KEY=your-random-secret-here
docker compose -f docker-compose.quickstart.yml up -d
docker compose -f docker-compose.dev.yml up --build
```
Open `http://localhost:5000`. The first user to register becomes admin. To connect Claude, create an API key under **Settings → API Keys** and install the Claude Code plugin — see [API Keys & MCP](docs/api-keys-and-mcp.md).
[Development](docs/development.md) covers checks, migrations, the plugin and CI. [Architecture](docs/architecture.md) describes how it is built, and the [API reference](docs/api-reference.md) lists every REST endpoint.
> **Development:** To build from source, see [Development](docs/development.md).
## Licence
## Documentation
| Doc | Contents |
|-----|----------|
| [Architecture](docs/architecture.md) | Stack, design decisions, data models, key services |
| [Configuration](docs/configuration.md) | Environment variables, Docker Compose, production setup, security |
| [Features](docs/features.md) | Detailed feature breakdown and keyboard shortcuts |
| [Development](docs/development.md) | Dev workflow, CI/CD, migrations, release process |
| [API Keys & MCP](docs/api-keys-and-mcp.md) | API key management and Fable MCP install guide |
| [SSO / OAuth](docs/sso-oauth.md) | OIDC setup for Authentik, Keycloak, and other providers |
| [API Reference](docs/api-reference.md) | All REST API endpoints |
## License
This project is privately maintained.
Fabled Grimoire is free software under the [GNU Affero General Public License v3.0 only](LICENSE) (`AGPL-3.0-only`). If you run a modified version for other people over a network, the licence requires you to offer them its source.
+4 -2
View File
@@ -4,8 +4,9 @@ from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from scribe.config import Config
from scribe.models import Base
from grimoire.config import Config
from grimoire.models import Base
from grimoire.services.db_ready import wait_for_database
config = context.config
if config.config_file_name is not None:
@@ -32,6 +33,7 @@ def do_run_migrations(connection):
async def run_async_migrations() -> None:
await wait_for_database(Config.DATABASE_URL)
engine = create_async_engine(Config.DATABASE_URL)
async with engine.connect() as connection:
await connection.run_sync(do_run_migrations)
@@ -1,13 +1,11 @@
"""replace events.end_dt with duration_minutes (Fable #160)
"""replace events.end_dt with duration_minutes
Revision ID: 0043
Revises: 0042
Create Date: 2026-04-29
Structural fix for the "end before start" bug class observed on prod
2026-04-29: an event landed with end_dt 32 days before start_dt due
to a tool-call mishap, then disappeared from upcoming-list filters.
Storing duration instead of end_dt makes the invalid state
Structural fix for the "end before start" bug class: an event whose end_dt
precedes its start_dt disappears from upcoming-list filters. Storing duration instead of end_dt makes the invalid state
inexpressible at the schema level (duration_minutes >= 0).
Backfill rules:
@@ -5,7 +5,7 @@ Revises: 0043
Create Date: 2026-05-13
Adds two columns to the ``notes`` table to support the task-as-durable-record
design (spec 2026-05-13):
design:
- ``description``: user-stated goal / initial context. Meaningful when
``is_task=true``; left NULL on knowledge notes.
@@ -4,7 +4,7 @@ Revision ID: 0048
Revises: 0047
Create Date: 2026-05-22
Phase 2 of the conversation+curator architecture (Fable #172). The
Part of the conversation+curator architecture. The
scheduler runs every 15 minutes and processes any journal conversation
with messages newer than its `last_curator_run_at` timestamp. NULL
means "never run; process all of today on first sweep."
@@ -4,7 +4,7 @@ Revision ID: 0049
Revises: 0048
Create Date: 2026-05-22
Phase 3 of the conversation+curator architecture (Fable #172). The
Part of the conversation+curator architecture. The
curator's final summary line (≤ 240 chars) is persisted here so the
NEXT chat turn can include it in the system prompt. This is the
feedback loop that closes the architecture — without it, the chat
@@ -4,13 +4,11 @@ Revision ID: 0053
Revises: 0052
Create Date: 2026-05-27
Phase 9 of the MCP-first pivot. The Python models for these tables were
deleted in Phase 8 (commit 91bafb6); this migration drops the orphan
Part of the MCP-first pivot. The Python models for these tables are gone;
this migration drops the orphan
SQL tables and cleans up dead per-user setting rows.
Hard-cutover: existing rows in these tables are discarded. The
accompanying spec at docs/superpowers/specs/2026-05-26-mcp-first-pivot-design.md
explicitly accepted this loss.
Hard-cutover: existing rows in these tables are discarded, deliberately.
"""
from alembic import op
+1 -1
View File
@@ -4,7 +4,7 @@ Revision ID: 0054
Revises: 0053
Create Date: 2026-05-27
The image cache was wired into the LLM image-search tool (removed in Phase 8).
The image cache was wired into the LLM image-search tool, which is gone.
With no producer or consumer left, the table is dropped here.
"""
from alembic import op
+2 -2
View File
@@ -6,8 +6,8 @@ Create Date: 2026-05-27
Adds the Scribe Rulebook hierarchy as a fourth top-level entity, sibling
to Project. Rules carry a structural (statement, why, how_to_apply)
triple. Projects subscribe to Rulebooks (many-to-many). See the design
doc at docs/superpowers/specs/2026-05-27-scribe-rulebook-design.md.
triple. Projects subscribe to Rulebooks (many-to-many; migration 0101
later drops subscriptions).
"""
from alembic import op
import sqlalchemy as sa
+1 -1
View File
@@ -4,7 +4,7 @@ Revision ID: 0066
Revises: 0065
Create Date: 2026-06-14
T3 of plan #819. The milestone becomes the plan container: its `body` holds
The milestone becomes the plan container: its `body` holds
the design/intent/purpose (markdown), `description` stays the one-liner, and
individual steps live as first-class child tasks (milestone_id) instead of
checkboxes crammed into a kind=plan task body. start_planning is reworked to
+2 -3
View File
@@ -4,8 +4,7 @@ Revision ID: 0074
Revises: 0073
Create Date: 2026-07-31
The operator's shape for #254: prose doesn't live as one-offs, it lives centrally
on the system. Two fields rather than one per category:
Prose doesn't live as one-offs; it lives centrally on the system. Two fields rather than one per category:
design_systems.guidance the narrative a token table cannot hold — aesthetic,
voice and tone, what is deliberately out of scope.
@@ -17,7 +16,7 @@ on the system. Two fields rather than one per category:
Free-form rather than a column per category on purpose. A schema with `voice`,
`aesthetic` and `scope` columns would bake one rulebook's table of contents into
every install (rule #115), and the next install's design system would have three
every install, and the next install's design system would have three
empty columns and nowhere to put what it actually cares about.
Both nullable: a design system with no prose at all is complete, not a draft.
+2 -2
View File
@@ -4,7 +4,7 @@ Revision ID: 0076
Revises: 0075
Create Date: 2026-08-07
Step 1 of milestone #278. Structure only — nothing reads or writes the new
Structure only — nothing reads or writes the new
table yet, and nothing behaves differently after this runs.
## What the table is for
@@ -39,7 +39,7 @@ and not for notes, so the column looked like the notes-side half of that
feature, modelled and abandoned.
It is dropped rather than repurposed for supersession, and the distinction is
the point (#2483): consolidation folds several records into one survivor and
the point: consolidation folds several records into one survivor and
destroys the originals. Merging two snippets is lossless — one helper, several
call sites. Folding two dev-logs means writing a summary and losing what each
actually said. Supersession is the opposite act: both records survive, and the
+1 -1
View File
@@ -1,4 +1,4 @@
"""Chunked embeddings: one note_embeddings row per chunk (#280)
"""Chunked embeddings: one note_embeddings row per chunk
Revision ID: 0077
Revises: 0076
+2 -2
View File
@@ -1,4 +1,4 @@
"""Forge connections move to the user level (#2778)
"""Forge connections move to the user level
Revision ID: 0078
Revises: 0077
@@ -12,7 +12,7 @@ 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
setting keys outright — no legacy dual-read. 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.
"""
+3 -3
View File
@@ -1,13 +1,13 @@
"""The shape ledger: code_shapes (#2787, milestone 294)
"""The shape ledger: code_shapes
Revision ID: 0079
Revises: 0078
Create Date: 2026-08-19
The accounting half of the pattern system (governing note 2786): the snippet
The accounting half of the pattern system: 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 /
from the coverage sync and gain judgments — canonical / instance /
variant / exempt — from audits, hooks, and the mechanical proposer.
Unclassified IS the todo list.
"""
@@ -1,4 +1,4 @@
"""Shape fingerprints + the mechanical proposer's columns (#2792, milestone 294)
"""Shape fingerprints + the mechanical proposer's columns
Revision ID: 0080
Revises: 0079
@@ -1,4 +1,4 @@
"""Shape history, recheck, and the divergence flag (#2793, milestone 294)
"""Shape history, recheck, and the divergence flag
Revision ID: 0081
Revises: 0080
+5 -5
View File
@@ -1,13 +1,13 @@
"""Per-binding ref — the branch a project's ledger follows (#2873, milestone 294)
"""Per-binding ref — the branch a project's ledger follows
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).
Without a ref, a repo binding implies the repo's default branch, so the shape
ledger only sees work after a merge — while a project may do its work on a
dev branch. `ref` names the branch the coverage refresh reads; NULL means the
forge's default branch.
"""
import sqlalchemy as sa
from alembic import op
@@ -1,4 +1,4 @@
"""Exempt/variant reason codes — a small fixed catalogue beside the prose (#2874, milestone 294)
"""Exempt/variant reason codes — a small fixed catalogue beside the prose
Revision ID: 0083
Revises: 0082
+1 -1
View File
@@ -1,4 +1,4 @@
"""code_shape_uses — consumption edges, separate from conformance (#2870, milestone 294)
"""code_shape_uses — consumption edges, separate from conformance
Revision ID: 0084
Revises: 0083
+1 -1
View File
@@ -1,4 +1,4 @@
"""Project inception: the decision record + always-on rulebook exclusions (milestone 297)
"""Project inception: the decision record + always-on rulebook exclusions
Revision ID: 0085
Revises: 0084
@@ -1,4 +1,4 @@
"""code_shape_consumers — the CSS consumer map (milestone 302, note 2917)
"""code_shape_consumers — the CSS consumer map
Revision ID: 0086
Revises: 0085
+3 -3
View File
@@ -1,12 +1,12 @@
"""canonical_systems — the global area vocabulary, promoted from a constant
to a table (milestone 307 step 1, decision note 3026)
to a table
Revision ID: 0087
Revises: 0086
Create Date: 2026-08-26
The eight standard area names already existed as `STANDARD_SYSTEMS`, a tuple in
services/systems.py that milestone 297 seeds into a project at inception. A
services/systems.py that is seeded into a project at inception. A
constant cannot be referenced: a rule that applies across projects has nothing
to point at, because `systems.project_id` is NOT NULL and a family rule cannot
be chained to one project's row. This makes the vocabulary a table so it can be
@@ -35,7 +35,7 @@ depends_on = None
# service computes (canonical_slug: lowercase, "&" -> "and", non-alphanumerics
# collapsed to "-"). Charters stay generic on purpose: a project refines its
# own System's description, never this one. Nothing here names an app, a repo,
# a vendor or a house convention — the catalog ships to every install (rule 115).
# a vendor or a house convention — the catalog ships to every install.
_SEED = (
("CI & Release", "ci-and-release",
"How the project is verified and shipped: pipelines, runners, image/artifact builds, release tagging and rollback."),
@@ -1,5 +1,4 @@
"""rules gain a trigger, a tier, canon tags and typed edges (milestone 307
step 3, decision note 3026)
"""rules gain a trigger, a tier, canon tags and typed edges
Revision ID: 0088
Revises: 0087
@@ -31,8 +30,8 @@ down_revision = "0087"
branch_labels = None
depends_on = None
# Kept in one place so upgrade and the CHECK agree by construction (rule 36:
# a whitelisted value means DROP + ADD CONSTRAINT in the same migration —
# Kept in one place so upgrade and the CHECK agree by construction (a
# whitelisted value means DROP + ADD CONSTRAINT in the same migration —
# there is no prior constraint here, so the pair is created together).
_TIERS = ("always_on", "conditional")
_RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
+1 -2
View File
@@ -1,5 +1,4 @@
"""rule_embeddings — rules become findable by meaning (milestone 307 step 4,
decision note 3026)
"""rule_embeddings — rules become findable by meaning
Revision ID: 0089
Revises: 0088
+4 -6
View File
@@ -1,5 +1,4 @@
"""a rule can carry its own check — verify_with, expires_when, verified_at
(milestone 312 step 1)
Revision ID: 0090
Revises: 0089
@@ -11,10 +10,9 @@ know they did. A CONSTRAINT is a fact about someone else's software: a
runner's shell, a bot's config, a tool that exists. Nobody is present when
that goes false.
Milestone 307's rulebook audit found nine stale sites. Every one was a
constraint; not one norm had rotted. One of them had been telling every
session to skip database-backed tests for weeks while the integration lane
sat green in the workflow.
What goes stale in a rulebook is the constraints, not the norms — and a
stale constraint keeps instructing every session that loads it, long after
the world it described has moved.
Three nullable columns, so a rule can say how to check itself:
@@ -31,7 +29,7 @@ none of them. A null `verify_with` is not an omission — it is the honest
marker of "this one is a decision, and there is nothing to go and check."
That signal only works if the field stays empty wherever it belongs empty.
No CHECK constraint is involved, so rule 36 does not apply here. Nothing is
No CHECK constraint is involved. Nothing is
backfilled: a migration cannot invent a check any more than 0088 could
invent a trigger.
"""
+2 -3
View File
@@ -1,5 +1,4 @@
"""task_kind gains 'spike' — the investigation, not the change
(milestone 312 step 5)
Revision ID: 0091
Revises: 0090
@@ -12,12 +11,12 @@ end of it. "Find out whether the runner can be given a bash shell" is not
work, and filing it as work makes a finished investigation look like an
abandoned change.
It is the record a failed check asks for. Milestone 312 gave rules a
It is the record a failed check asks for. Rules carry a
`verify_with`; when one of those fails, the rule is wrong and the next move
is often to go and find out what replaced it. `notes.arose_from_id` already
exists (0065), so that constraint -> spike link needs no further schema.
Rule 36: `task_kind` is gated by a CHECK whitelist, so the value and the
`task_kind` is gated by a CHECK whitelist, so the value and the
widened constraint land in the SAME migration — DROP then ADD, exactly as
0065 did when it introduced 'issue'. Adding the value and constraining it
later leaves a window where the database accepts anything.
+4 -7
View File
@@ -1,5 +1,4 @@
"""a note can carry its own check — verify_with, expires_when, verified_at
(milestone 317 step 1)
Revision ID: 0092
Revises: 0091
@@ -32,8 +31,7 @@ Three nullable columns:
WHICH ROWS THESE ARE FOR. `notes` is one table holding notes, tasks,
snippets and processes, so these columns land on all of them. Only non-task,
non-snippet records are OFFERED them (milestone 317 decisions 1 and 2, gated
at the service in step 2): a task's decay is its status, and a snippet
non-snippet records are OFFERED them (gated at the service): a task's decay is its status, and a snippet
already carries a richer, location-aware verdict in `data.verification`. The
columns exist on the other rows and stay null there; a gate that lives in
the schema would have meant a partial index or a CHECK across three columns
@@ -44,7 +42,7 @@ value of the sweep is that its output is short. A null `verify_with` is not
an omission; it is the honest marker of "this one is a decision, and there
is nothing to go and check."
No CHECK constraint is involved, so rule 36 does not apply. Nothing is
No CHECK constraint is involved. Nothing is
backfilled: a migration cannot invent a check.
"""
import sqlalchemy as sa
@@ -69,9 +67,8 @@ def upgrade() -> None:
#
# Still a sequential scan's job at this size, and an index on
# (verified_at) filtered to `verify_with IS NOT NULL` would be maintained
# on every note write to serve one operator-initiated query. If step 3's
# live acceptance measures otherwise, add it there against a real plan
# rather than guessing here.
# on every note write to serve one operator-initiated query. If a real
# plan says otherwise, add it then rather than guessing here.
def downgrade() -> None:
+5 -8
View File
@@ -1,4 +1,4 @@
"""rules gain an edit history — rule_versions (milestone 323 step 1)
"""rules gain an edit history — rule_versions
Revision ID: 0093
Revises: 0092
@@ -7,12 +7,9 @@ Create Date: 2026-08-29
The sibling `note_versions` has had for a long time. A note's every meaningful
edit is snapshotted, and the design-system note calls that history "the
changelog". A RULE — which binds behaviour on every session that loads it —
had nothing: an edit destroyed what it used to say, with no record anywhere.
Rescoping rule 79 on 2026-08-29 is what surfaced it. The superseded statement
had to be hand-copied into a task log to survive the edit (#3237), which is
not a process, it is a person remembering. The more consequential record had
the weaker protection.
had nothing: an edit destroyed what it used to say, with no record anywhere,
and keeping a superseded statement meant a person remembering to copy it
somewhere. The more consequential record had the weaker protection.
Three things are deliberately NOT copied from note_versions, and each is a
guard that exists there for a reason that does not hold here:
@@ -31,7 +28,7 @@ guard that exists there for a reason that does not hold here:
CASCADE: deleting a user must not erase the history of the rules they edited.
The edit still happened and the rule still binds because of it.
No CHECK constraint, so rule 36 does not apply. Nothing is backfilled — a
No CHECK constraint is involved. Nothing is backfilled — a
migration cannot invent the text a rule used to have, and inventing "the
current text, as of now" would be worse than an empty history, because it
would look like a record of an edit that never occurred.
+7 -10
View File
@@ -1,4 +1,4 @@
"""add rule_usage_events — was a surfaced rule ever read? (milestone 333 step 1)
"""add rule_usage_events — was a surfaced rule ever read?
Revision ID: 0094
Revises: 0093
@@ -9,18 +9,15 @@ table to arrive after `rule_embeddings` and `rule_versions` — each one added
because the rule side kept inheriting machinery built for notes and getting
the weaker version of it.
WHAT IT MEASURES. The write-path standing-rule arm is the only retrieval
surface in Scribe whose usefulness cannot be observed, and — not coincidentally
— the only one that has never declined to fire. Over 30 days it took 296 calls,
returned something on every one, and cleared its threshold 100% of the time,
while every other surface declines most of the time (#3311). That is either a
perfectly tuned surface or a bar it cannot fail to clear, and `retrieval_logs`
WHAT IT MEASURES. A standing-rule arm that returns something on every call
and clears its threshold every time is either a perfectly tuned surface or a
bar it cannot fail to clear, and `retrieval_logs`
cannot tell them apart: it records what the ranker scored, never whether the
hint was any use.
WHY NOT A rule_id COLUMN ON note_usage_events. The row shares no note-specific
fields and the aggregate readout is the same shape, which is the strongest case
for sharing that note #3163 admits. What decides against it is identity at
for sharing there is. What decides against it is identity at
RESTORE: `note_usage_events`'s importer maps `note_id` through `note_id_map`
and drops what does not resolve. A rule id parked in that column would come
back from a backup silently reattached to whatever note took that number —
@@ -34,8 +31,8 @@ and dies with it; telemetry outlives the row it describes. Deleting a rule must
not erase the evidence that it was surfaced forty times and opened never, since
that evidence is exactly the case for having deleted it.
No CHECK on `event`, matching the note twin. Rule 36 governs adding a value to
a column that is already gated; it does not require gating one that never was,
No CHECK on `event`, matching the note twin. DROP + ADD governs adding a value
to a column that is already gated; it does not require gating one that never was,
and a two-member enum whose members are written by two functions in one module
is not where that discipline earns its cost.
@@ -1,4 +1,4 @@
"""add retrieval_logs.suppressed_count — tell a ranker decline from a repeat (#3497)
"""add retrieval_logs.suppressed_count — tell a ranker decline from a repeat
Revision ID: 0095
Revises: 0094
@@ -20,9 +20,8 @@ the dropped rows never come back and there is nothing to count.
NULLABLE, AND THE NULL IS THE POINT. A surface that does not measure
suppression stores NULL, not 0, and the readout renders it as "not measured"
rather than "none". Defaulting to 0 would make an unmeasured surface look like
a perfectly clean one — the exact substitution of an artifact for a
measurement that #3311 made and that #3497 exists to correct. Doing it again,
in the migration that fixes it, would be its own small joke.
a perfectly clean one — the substitution of an artifact for a measurement
that this column exists to prevent.
No backfill for the same reason: existing rows genuinely do not know, and
saying so is the honest state. `retrieval_logs` is not restored from backup,
@@ -1,4 +1,4 @@
"""add retrieval_logs.best_available_score — the score the bar rejected (#3670)
"""add retrieval_logs.best_available_score — the score the bar rejected
Revision ID: 0096
Revises: 0095
@@ -34,7 +34,7 @@ NULLABLE, AND UNBACKFILLED, for the reason 0095 spells out: a row written
before this shipped genuinely does not know what its best rejected candidate
scored, and saying so is the honest state. A 0.0 default would read as "the
corpus had nothing remotely relevant" — an artifact standing in for a
measurement, which is the whole defect this milestone corrects.
measurement, which is the defect this column corrects.
`retrieval_logs` is not restored from backup, so no importer changes.
@@ -1,4 +1,4 @@
"""add retrieval_logs.best_available_id — WHICH record the bar refused (#3807)
"""add retrieval_logs.best_available_id — WHICH record the bar refused
Revision ID: 0097
Revises: 0096
+2 -2
View File
@@ -1,4 +1,4 @@
"""rules gain a `kind` — a preference is a rule that does not bind (#3849)
"""rules gain a `kind` — a preference is a rule that does not bind
Revision ID: 0098
Revises: 0097
@@ -37,7 +37,7 @@ install upgrades and every existing row keeps the force it had. That is the
same reasoning 0088 used for `tier`, and it is the reason both are safe to
apply without reading the data first.
The CHECK is created with the column (rule 36: there is no prior constraint,
The CHECK is created with the column (there is no prior constraint,
so the pair is created together — a value added to it LATER does DROP + ADD
in one migration).
@@ -7,7 +7,7 @@ Create Date: 2026-09-11
`pre_tool_rule` retrieves against the RAW COMMAND TEXT and `write_path_rule`
against the code being written, so whatever was on the command line or in the
buffer is what `record_retrieval` wrote into `retrieval_logs.query`. A command
that exported a token stored the token (#3925).
that exported a token stored the token.
`services/retrieval_telemetry.scrub_secrets` closes that going forward — the
value never reaches the column. It cannot reach BACKWARDS, and this does: it
+2 -2
View File
@@ -4,14 +4,14 @@ Revision ID: 0100
Revises: 0099
Create Date: 2026-09-11
Milestone 394. Every rule now reaches a session by retrieval — because
Every rule reaches a session by retrieval — because
something it is about to do made the rule relevant — and the machinery that
delivered rules unconditionally goes with it.
WHAT GOES, AND WHERE IT CAME FROM
- ``rules.tier`` and its ``ck_rules_tier`` CHECK (migration 0088). Dropping
the column takes the constraint with it. Rule 36 is about ADDING a value to
the column takes the constraint with it. DROP + ADD is about ADDING a value to
a live whitelist, which needs DROP + ADD in the same migration; it does not
speak to removing the column outright, and saying so here is cheaper than
the next reader wondering whether it was forgotten.
@@ -4,12 +4,12 @@ Revision ID: 0101
Revises: 0100
Create Date: 2026-09-15
Milestone 414. A rule lives in a rulebook topic, where it is GLOBAL, or on one
project, and retrieval reads that home directly (step 1). Subscriptions were
the last thing that pretended a rulebook reached some projects and not others,
and after milestone 394 they changed nothing a session received — only what a
project's rule LISTING showed. Operator, 2026-09-15: "we have global and
project scoped rules, we don't need the subscriptions now."
A rule lives in a rulebook topic, where it is GLOBAL, or on one project, and
retrieval reads that home directly. Subscriptions were the last thing that
pretended a rulebook reached some projects and not others, and with no
always-on tier they changed nothing a session received — only what a
project's rule LISTING showed. Global and project-scoped rules cover what
subscriptions were for.
WHAT GOES
@@ -19,8 +19,8 @@ WHAT GOES
there is nothing to mute; a project that departs from a global rule writes
a project rule with an ``overrides`` relation, which says why.
- The ``subscribe_rulebooks`` key inside ``projects.inception.choices``, and
the ``exclude_always_on_rulebooks`` key milestone 394 left behind in the
same place. Both describe decisions that can no longer be made; a stored
the ``exclude_always_on_rulebooks`` key the retired always-on tier left
in the same place. Both describe decisions that can no longer be made; a stored
record carrying them would be read back as a choice the product offers.
IRREVERSIBLE, AND THE DOWNGRADE SAYS SO
@@ -1,4 +1,4 @@
"""milestone_embeddings — a plan becomes findable by meaning (milestone 415)
"""milestone_embeddings — a plan becomes findable by meaning
Revision ID: 0102
Revises: 0101
@@ -9,8 +9,8 @@ lives in — could not be found at all. So "is there already a plan for this?"
had no tool, and a project whose roadmap was written as milestones had every
later plan opened as a new milestone beside the one that already described it.
The sibling of rule_embeddings (0089), for the reasons its model docstring and
note 3163 give. The vectors are DERIVED: nothing is backfilled here, the startup
The sibling of rule_embeddings (0089), for the reasons its model docstring
gives. The vectors are DERIVED: nothing is backfilled here, the startup
backfill writes them.
"""
import sqlalchemy as sa
@@ -1,16 +1,12 @@
"""retrieval_tuning_events — why a floor is where it is (#4102)
"""retrieval_tuning_events — why a floor is where it is
Revision ID: 0103
Revises: 0102
Create Date: 2026-09-17
Milestone 416 stops shipping similarity thresholds as values somebody has to
defend, and hands the adjustment to the model that reads the surface's own
telemetry. The operator's decision:
"the floor should be chosen and adjusted by the model using it… the user
should be able to touch it but the model should be the thing handling it 9
times out of 10."
Similarity thresholds are not values somebody has to defend: the model that
reads the surface's own telemetry adjusts them, and the operator can still
touch them.
The number itself already has a home — the generic settings table. What has no
home is the ARGUMENT, and once the values move on their own the argument is the
@@ -1,10 +1,10 @@
"""retrieval_tuning_events carries the space each number was measured in (#4104)
"""retrieval_tuning_events carries the space each number was measured in
Revision ID: 0104
Revises: 0103
Create Date: 2026-09-17
Milestone 416 step 6. A retrieval floor is a cosine distance in ONE embedding
A retrieval floor is a cosine distance in ONE embedding
model's geometry, computed over documents cut one particular way. Change the
model and every score moves at once; change the chunker and the same record
embeds different text. Either way a number chosen before the change is a
@@ -16,7 +16,7 @@ merely unknown.
the startup backfill re-embeds precisely what is stale instead of wiping the
table. These two columns are that idea applied to the tuned numbers.
TWO COLUMNS, NOT ONE (rule 149). A reader has to be able to say WHICH half
TWO COLUMNS, NOT ONE. A reader has to be able to say WHICH half
moved: a new embedding model and a re-cut document shape invalidate the same
numbers for different reasons, and a fused `"<model>@<n>"` could only report
that something changed.
@@ -1,20 +1,19 @@
"""note_usage_events records WHICH project the reader was in (#4196, #3735)
"""note_usage_events records WHICH project the reader was in
Revision ID: 0105
Revises: 0104
Create Date: 2026-09-20
Milestone 385. `RetrievalLog` has carried `project_id` from the start, so
`RetrievalLog` has carried `project_id` from the start, so
"this record was SURFACED on project B" has always been answerable. The usage
table never had one, so "this record was OPENED on project B" never was — and
the two cannot be joined to recover it, because there is no session identity
server-side (see the NoteUsageEvent docstring, which rules that out on
purpose).
That gap sits exactly on the question milestone 385 exists to answer. A
lesson's whole claim is that it reaches a session on a project it was not
written on; step 8's acceptance is "retrieved on a different project, at the
moment it applies, AND opened". Surfacing cross-project: answerable. Opening:
That gap sits exactly on the question lessons exist to answer. A lesson's
whole claim is that it reaches a session on a project it was not written on:
"retrieved on a different project, at the moment it applies, AND opened". Surfacing cross-project: answerable. Opening:
answerable. Both at once: not, until this column.
WHICH PROJECT THIS IS, because the name is ambiguous and the wrong reading
@@ -1,10 +1,10 @@
"""rule_usage_events carry an outcome, not just a read (#4212, milestone 419)
"""rule_usage_events carry an outcome, not just a read
Revision ID: 0106
Revises: 0105
Create Date: 2026-09-20
Milestone 419's first step. `rule_usage_events` can say a rule was SURFACED
`rule_usage_events` can say a rule was SURFACED
and that it was PULLED. It cannot say what happened next, so these two
sessions leave identical telemetry:
@@ -12,16 +12,14 @@ sessions leave identical telemetry:
- a rule surfaced, opened, and silently ignored.
The second is the more urgent by a distance, and it is the one the readout
cannot name. That is the whole of what this milestone is about, measured on a
session where three of seven misses were caught by the operator and none by
the system.
cannot name, and it is what an outcome column is for.
TWO NEW EVENT VALUES, AND NO CHECK MIGRATION. `event` was created in 0094 as
plain `sa.Text()` with no constraint — checked in the migration itself, not
assumed from the model — so `applied` and `departed` join `surfaced` and
`pulled` without a DROP/ADD pair. Rule 36 governs CHECK-whitelisted columns
`pulled` without a DROP/ADD pair. That pair is for CHECK-whitelisted columns
and this is not one; noted explicitly because the next reader will reach for
rule 36 here, and should be able to see in one place why it does not bite.
it here, and should be able to see in one place why it does not apply.
THE THIRD STATE IS DERIVED, AND THAT IS NOT A SHORTCUT. Read-and-silently-
unchanged is the absence of an outcome, and it has to be: an agent that knew
@@ -29,8 +27,8 @@ it was ignoring a rule would not be ignoring it. There is no honest way to ask
for that event, so nothing here tries. `applied` and `departed` are reported;
the third state is what is left over when a rule was pulled and neither
arrived. A schema that offered an `ignored` value would collect nothing and
read as though it had measured something, which is the #3311 failure — a
statistic that cannot vary being mistaken for a finding.
read as though it had measured something — a statistic that cannot vary
being mistaken for a finding.
`detail` CARRIES THE WHY OF A DEPARTURE, and is the reason this is a column
rather than two more bare event strings. A departure without its reason is
+2 -3
View File
@@ -1,4 +1,4 @@
"""system_embeddings — a charter becomes an ANSWER, not just a filter (#4251)
"""system_embeddings — a charter becomes an ANSWER, not just a filter
Revision ID: 0107
Revises: 0106
@@ -13,8 +13,7 @@ to one, and an agent asking where a record belonged had to read every charter
or guess.
The fourth sibling of note_embeddings (0067), rule_embeddings (0089) and
milestone_embeddings (0102), and for the same reason note 3163 gives about the
third: the row could be shared, the search cannot. "Where does this belong?"
milestone_embeddings (0102), and for the same reason as the third: the row could be shared, the search cannot. "Where does this belong?"
is a different question from "what prior art is there?", and no note or rule
search can answer it, because a charter is not a note.
@@ -1,17 +1,16 @@
"""trigger_leaves_the_stored_title — a snippet's and lesson's title is its name (milestone 427)
"""trigger_leaves_the_stored_title — a snippet's and lesson's title is its name
Revision ID: 0108
Revises: 0107
Create Date: 2026-09-23
Snippets and lessons stored their title as `subject — trigger`, because the
join is what makes these kinds rank on the situation they apply to (#2485) and
join is what makes these kinds rank on the situation they apply to and
the stored title WAS the embedded one. Every surface that shows a title then
showed the trigger too: menu lines, lists and search rows ran to 1,500–3,000
characters, and an injected repeat spent all of it again.
The trigger's home is `data` (decision #4157), and since this milestone the
join happens at embed time (`embeddings.document_title`). This revision
The trigger's home is `data`, and the join happens at embed time (`embeddings.document_title`). This revision
rewrites the rows already stored.
EXACT-SUFFIX, READ FROM THE ROW'S OWN MIRROR. A title is only rewritten when
@@ -1,4 +1,4 @@
"""embeddings_record_their_model — a vector says whose space it lives in (#4132)
"""embeddings_record_their_model — a vector says whose space it lives in
Revision ID: 0109
Revises: 0108
+1 -1
View File
@@ -1,4 +1,4 @@
"""task_claims — a task can say a session is working it (milestone 381 step 2)
"""task_claims — a task can say a session is working it
Revision ID: 0110
Revises: 0109
+2 -3
View File
@@ -1,5 +1,4 @@
"""lesson_rule_links — a lesson points at the rule it is an instance of
(milestone 440 step 1, #4196)
Revision ID: 0111
Revises: 0110
@@ -21,8 +20,8 @@ down_revision = "0110"
branch_labels = None
depends_on = None
# One place, so the CHECK and the model's LINK_STATES cannot drift (rule 36:
# a new value later means DROP + ADD CONSTRAINT in the same migration).
# One place, so the CHECK and the model's LINK_STATES cannot drift (a new
# value later means DROP + ADD CONSTRAINT in the same migration).
_STATES = ("suggested", "confirmed", "rejected")
+1 -2
View File
@@ -1,5 +1,4 @@
"""lesson_no_rule — "no rule fits" is a recorded answer (milestone 440 step 2,
#4631)
"""lesson_no_rule — "no rule fits" is a recorded answer
Revision ID: 0112
Revises: 0111
@@ -0,0 +1,33 @@
"""system_path_patterns — a System names the files that are its area
Revision ID: 0113
Revises: 0112
Create Date: 2026-10-02
A JSONB list of globs relative to the repo root. NOT NULL with a `[]`
default, so an existing System reads as "no paths yet" rather than as NULL. No
backfill: which files are which area is a judgment, and the charters that name
directories in prose are not a mapping anyone confirmed.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0113"
down_revision = "0112"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"systems",
sa.Column(
"path_patterns", postgresql.JSONB(), nullable=False,
server_default=sa.text("'[]'::jsonb"),
),
)
def downgrade() -> None:
op.drop_column("systems", "path_patterns")
@@ -0,0 +1,45 @@
"""system_usage_events — were an area's rulings read once its files were
touched?
Revision ID: 0114
Revises: 0113
Create Date: 2026-10-03
The usage twin for Systems, beside note_usage_events and rule_usage_events and
separate from both for the same reason they are separate from each other: a
System id restores through its own map. FK-free like its siblings; no CHECK on
`event`, like its siblings.
"""
import sqlalchemy as sa
from alembic import op
revision = "0114"
down_revision = "0113"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"system_usage_events",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column("user_id", sa.BigInteger(), nullable=True),
sa.Column("system_id", sa.BigInteger(), nullable=False),
sa.Column("event", sa.Text(), nullable=False),
sa.Column("source", sa.Text(), nullable=False),
sa.Column("project_id", sa.BigInteger(), nullable=True),
)
op.create_index(
"ix_system_usage_system_event", "system_usage_events", ["system_id", "event"]
)
op.create_index("ix_system_usage_created_at", "system_usage_events", ["created_at"])
def downgrade() -> None:
op.drop_index("ix_system_usage_created_at", table_name="system_usage_events")
op.drop_index("ix_system_usage_system_event", table_name="system_usage_events")
op.drop_table("system_usage_events")
@@ -0,0 +1,55 @@
"""retrieval_judgments — a reviewer's verdict on each line of a logged menu
Revision ID: 0115
Revises: 0114
Create Date: 2026-10-03
Whether an injected line RELATED, which the usage tables cannot say: since
every line carries its matched passage, "not opened" no longer implies "not
relevant". FK-free like the telemetry tables beside it; no CHECK on `verdict`,
which the service validates.
"""
import sqlalchemy as sa
from alembic import op
revision = "0115"
down_revision = "0114"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"retrieval_judgments",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column("user_id", sa.BigInteger(), nullable=True),
sa.Column("retrieval_log_id", sa.BigInteger(), nullable=False),
sa.Column("source", sa.Text(), nullable=False),
sa.Column("query", sa.Text(), nullable=True),
sa.Column("record_id", sa.BigInteger(), nullable=False),
sa.Column("rank", sa.Integer(), nullable=False),
sa.Column("score", sa.Float(), nullable=True),
sa.Column("within_budget", sa.Boolean(), nullable=False),
sa.Column("verdict", sa.Text(), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("opened_after", sa.Boolean(), nullable=True),
sa.UniqueConstraint(
"retrieval_log_id", "record_id", "user_id",
name="uq_retrieval_judgment_line",
),
)
op.create_index(
"ix_retrieval_judgments_source_created", "retrieval_judgments",
["source", "created_at"],
)
def downgrade() -> None:
op.drop_index(
"ix_retrieval_judgments_source_created", table_name="retrieval_judgments",
)
op.drop_table("retrieval_judgments")
+47
View File
@@ -0,0 +1,47 @@
"""moment_mappings — an install's own corrections to which actions reach which
moment
Revision ID: 0116
Revises: 0115
Create Date: 2026-10-05
The moments and their default actions are code; this table holds what an
install adds ("make ship" is a deliver here) and the defaults it switches off.
User-owned configuration, so it cascades with the user. No CHECK on `effect`,
which the service validates.
"""
import sqlalchemy as sa
from alembic import op
revision = "0116"
down_revision = "0115"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"moment_mappings",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"user_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
),
sa.Column("tool", sa.Text(), nullable=False),
sa.Column("match", sa.Text(), nullable=False, server_default=""),
sa.Column("moment", sa.Text(), nullable=False),
sa.Column("effect", sa.Text(), nullable=False, server_default="add"),
sa.Column("reason", sa.Text(), nullable=False, server_default=""),
sa.Column("actor", sa.Text(), nullable=False, server_default="model"),
sa.UniqueConstraint(
"user_id", "tool", "match", "moment", name="uq_moment_mapping",
),
)
def downgrade() -> None:
op.drop_table("moment_mappings")
+38
View File
@@ -0,0 +1,38 @@
"""rule_moments — the moments of work a rule arrives at
Revision ID: 0117
Revises: 0116
Create Date: 2026-10-05
A rule mounted on a moment is delivered whenever that moment happens, by
lookup rather than by score. The moment is a catalog name, validated by the
service; the catalog is code, so there is no table to key it to. Cascades
with the rule, like rule_systems beside it.
"""
import sqlalchemy as sa
from alembic import op
revision = "0117"
down_revision = "0116"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"rule_moments",
sa.Column(
"rule_id", sa.BigInteger(),
sa.ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True,
),
sa.Column("moment", sa.Text(), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
)
# The delivery read asks "which rules are on this moment?", the
# opposite direction from the primary key.
op.create_index("ix_rule_moments_moment", "rule_moments", ["moment"])
def downgrade() -> None:
op.drop_index("ix_rule_moments_moment", table_name="rule_moments")
op.drop_table("rule_moments")
@@ -0,0 +1,60 @@
"""rule_moment_judgments — whether a rule belongs on a moment, and who said so
Revision ID: 0118
Revises: 0117
Create Date: 2026-10-05
One row per (rule, moment) with a state: `suggested` while a proposal waits
for a judgment, `confirmed` or `rejected` once one is made. The mount itself
stays in rule_moments; this records the proposals that are not mounts yet and
the rejections that must stop them being proposed again. Moment '' is "no
moment fits this rule". CASCADE on the rule — a judgment about a rule that no
longer exists says nothing. No backfill: the mounts made before this table
were judgments too, but recording them now would invent who made them.
"""
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision = "0118"
down_revision = "0117"
branch_labels = None
depends_on = None
# One place each, so the CHECKs and the model's JUDGMENT_STATES / SOURCES
# cannot drift (a new value later means DROP + ADD CONSTRAINT in the same
# migration).
_STATES = ("suggested", "confirmed", "rejected")
_SOURCES = ("pass", "signal", "edit")
def upgrade() -> None:
op.create_table(
"rule_moment_judgments",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False),
sa.Column("moment", sa.Text(), nullable=False),
sa.Column("state", sa.Text(), nullable=False, server_default="suggested"),
sa.Column("source", sa.Text(), nullable=False, server_default="pass"),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("evidence", postgresql.JSONB(), nullable=True),
sa.Column("judged_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.UniqueConstraint("rule_id", "moment", name="uq_rule_moment_judgments_pair"),
)
op.create_check_constraint(
"ck_rule_moment_judgments_state", "rule_moment_judgments",
"state IN (" + ", ".join(f"'{s}'" for s in _STATES) + ")",
)
op.create_check_constraint(
"ck_rule_moment_judgments_source", "rule_moment_judgments",
"source IN (" + ", ".join(f"'{s}'" for s in _SOURCES) + ")",
)
op.create_index("ix_rule_moment_judgments_rule_id", "rule_moment_judgments", ["rule_id"])
def downgrade() -> None:
op.drop_index("ix_rule_moment_judgments_rule_id", table_name="rule_moment_judgments")
op.drop_constraint("ck_rule_moment_judgments_source", "rule_moment_judgments", type_="check")
op.drop_constraint("ck_rule_moment_judgments_state", "rule_moment_judgments", type_="check")
op.drop_table("rule_moment_judgments")
@@ -0,0 +1,36 @@
"""rule_moment_judgments.misfire — a mount reported as arriving where it does
not apply
Revision ID: 0119
Revises: 0118
Create Date: 2026-10-05
The open-after-moment signal proposes a mount; nothing proposed taking one
off. This column is the evidence for that direction: each time a session says
a mounted rule arrived at a moment where it did not apply, the report is
counted here per distinct day, with its reason and the action that reached
the moment. At the bar it becomes an unmount proposal for the operator.
A column on the judgment row rather than a table of its own: a misfire is
always about a (rule, moment) pair that is mounted, and that pair already has
its row. Nullable and no backfill — nobody has reported a misfire yet.
"""
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision = "0119"
down_revision = "0118"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"rule_moment_judgments",
sa.Column("misfire", postgresql.JSONB(), nullable=True),
)
def downgrade() -> None:
op.drop_column("rule_moment_judgments", "misfire")
+288
View File
@@ -0,0 +1,288 @@
"""family canon — platforms, family ideas, the adoption ledger and its decision
log
Revision ID: 0120
Revises: 0119
Create Date: 2026-10-06
When one project solves something every project on the same platform will
meet, that solution becomes FAMILY CANON, and every other project on the
platform answers it: adopted, a variant (with a reason), exempt (with a
reason), or owed. These tables hold that.
- `platforms` — a GLOBAL catalog, the canonical_systems shape: no owner, slug
is the match key. Seeded with generic technology names only. Nothing here
names an app, a repo or a house convention: the catalog ships to every
install.
- `project_platforms` — which platforms a project is (declared / detected /
rejected). Membership is what makes "is this in family?" a lookup.
- `family_ideas` — a NOTE's family state. No new record type: any notes row
(a note, a snippet, a lesson) becomes an idea by gaining one of these.
- `family_idea_platforms` — the platforms an idea is for; the only scope
source. A linked rule topic takes its scope from the idea.
- `family_idea_references` — reference implementations (snippets).
- `family_adoptions` — one row per (project, idea): the project's answer.
- `family_decisions` — append-only log of every promotion and ledger change.
No backfill. No project has declared a platform and no note is an idea yet;
inventing either would assert a judgment nobody made. The seed rows are
written here verbatim rather than imported, so the migration keeps running
unchanged after any service-side list moves on.
"""
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision = "0120"
down_revision = "0119"
branch_labels = None
depends_on = None
# One place per whitelist, so each CHECK and the model's tuple cannot drift
# (a new value later means DROP + ADD CONSTRAINT in one migration).
_MEMBERSHIP = ("declared", "detected", "rejected")
_IDEA_STATUSES = ("candidate", "canon", "retired")
_ADOPTION_STATUSES = ("unassessed", "adopted", "variant", "exempt", "owed")
_REASONED = ("variant", "exempt")
_ACTIONS = ("propose", "promote", "revise", "retire", "assess", "undo")
_DECIDERS = ("agent", "operator", "system")
def _in(column: str, values: tuple[str, ...]) -> str:
return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")"
# (name, slug, description, markers). A marker with no slash matches a file's
# basename anywhere in a repo; one with a slash matches the repo-relative
# path. Empty markers = declare-only, for a platform that leaves no reliable
# file behind. Generic on purpose — an install adds its own.
_SEED = (
("Android app", "android-app",
"A native Android client: an APK or app bundle installed on phones and tablets.",
["AndroidManifest.xml"]),
("iOS app", "ios-app",
"A native iOS or iPadOS client built with Xcode.",
["project.pbxproj"]),
("Web frontend", "web-frontend",
"A browser client built with a frontend toolchain: single-page apps and server-rendered frontends.",
["vite.config.*", "svelte.config.*", "vue.config.*", "next.config.*",
"nuxt.config.*", "angular.json"]),
("Browser extension", "browser-extension",
"An extension installed into a web browser and distributed through its add-on store or by file.",
[]),
("Desktop app", "desktop-app",
"A packaged desktop application for Windows, macOS or Linux.",
["tauri.conf.json", "electron-builder.*"]),
("Container image", "container-image",
"Ships as an OCI/Docker image that a host pulls and runs.",
["Dockerfile", "Containerfile", "*.Dockerfile"]),
("Go", "go",
"Written in Go.",
["go.mod"]),
("Python", "python",
"Written in Python.",
["pyproject.toml", "setup.py", "requirements.txt"]),
("Rust", "rust",
"Written in Rust.",
["Cargo.toml"]),
("PostgreSQL", "postgresql",
"Keeps its data in PostgreSQL.",
[]),
("GitHub Actions", "github-actions",
"Verified and released by GitHub Actions workflows.",
[".github/workflows/*"]),
("Gitea / Forgejo Actions", "gitea-actions",
"Verified and released by Gitea or Forgejo Actions workflows.",
[".gitea/workflows/*", ".forgejo/workflows/*"]),
)
def upgrade() -> None:
platforms = op.create_table(
"platforms",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("slug", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("markers", postgresql.JSONB(), nullable=False,
server_default=sa.text("'[]'::jsonb")),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
)
# Unique among LIVE rows only (the canonical_systems convention).
op.create_index(
"uq_platforms_slug", "platforms", ["slug"],
unique=True, postgresql_where=sa.text("deleted_at IS NULL"),
)
op.bulk_insert(
platforms,
[
{"name": name, "slug": slug, "description": description,
"markers": markers, "order_index": index}
for index, (name, slug, description, markers) in enumerate(_SEED)
],
)
op.create_table(
"project_platforms",
sa.Column("project_id", sa.Integer(),
sa.ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
sa.Column("platform_id", sa.Integer(),
sa.ForeignKey("platforms.id", ondelete="CASCADE"), primary_key=True),
sa.Column("state", sa.Text(), nullable=False, server_default="declared"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
op.create_check_constraint(
"ck_project_platforms_state", "project_platforms", _in("state", _MEMBERSHIP),
)
op.create_index("ix_project_platforms_platform_id", "project_platforms", ["platform_id"])
op.create_table(
"family_ideas",
sa.Column("note_id", sa.Integer(),
sa.ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
sa.Column("status", sa.Text(), nullable=False, server_default="candidate"),
sa.Column("applies_when", sa.Text(), nullable=True),
sa.Column("canon_version", sa.Integer(), nullable=False, server_default="1"),
# SET NULL: deleting a topic must not delete the standard it served.
sa.Column("topic_id", sa.BigInteger(),
sa.ForeignKey("rulebook_topics.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
op.create_check_constraint(
"ck_family_ideas_status", "family_ideas", _in("status", _IDEA_STATUSES),
)
# The first promotion criterion, held by the schema: an idea is not canon
# until it says, in platform terms, when it applies.
op.create_check_constraint(
"ck_family_ideas_canon_applies", "family_ideas",
"status <> 'canon' OR length(btrim(coalesce(applies_when, ''))) > 0",
)
op.create_check_constraint(
"ck_family_ideas_canon_version", "family_ideas", "canon_version >= 1",
)
op.create_index(
"uq_family_ideas_topic", "family_ideas", ["topic_id"],
unique=True, postgresql_where=sa.text("topic_id IS NOT NULL"),
)
op.create_index("ix_family_ideas_status", "family_ideas", ["status"])
op.create_table(
"family_idea_platforms",
sa.Column("note_id", sa.Integer(),
sa.ForeignKey("family_ideas.note_id", ondelete="CASCADE"), primary_key=True),
sa.Column("platform_id", sa.Integer(),
sa.ForeignKey("platforms.id", ondelete="CASCADE"), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
op.create_index("ix_family_idea_platforms_platform_id", "family_idea_platforms", ["platform_id"])
op.create_table(
"family_idea_references",
sa.Column("idea_id", sa.Integer(),
sa.ForeignKey("family_ideas.note_id", ondelete="CASCADE"), primary_key=True),
sa.Column("snippet_id", sa.Integer(),
sa.ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
op.create_index("ix_family_idea_references_snippet_id", "family_idea_references", ["snippet_id"])
op.create_table(
"family_adoptions",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column("project_id", sa.Integer(),
sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
sa.Column("idea_id", sa.Integer(),
sa.ForeignKey("family_ideas.note_id", ondelete="CASCADE"), nullable=False),
sa.Column("status", sa.Text(), nullable=False, server_default="unassessed"),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("canon_version", sa.Integer(), nullable=True),
sa.Column("assessed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("decided_via", sa.Text(), nullable=True),
sa.Column("owed_task_id", sa.Integer(),
sa.ForeignKey("notes.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
op.create_check_constraint(
"ck_family_adoptions_status", "family_adoptions", _in("status", _ADOPTION_STATUSES),
)
# A departure is only a record if it says why.
op.create_check_constraint(
"ck_family_adoptions_reason", "family_adoptions",
"NOT (" + _in("status", _REASONED) + ") "
"OR length(btrim(coalesce(reason, ''))) > 0",
)
op.create_check_constraint(
"ck_family_adoptions_decided_via", "family_adoptions",
"decided_via IS NULL OR " + _in("decided_via", _DECIDERS),
)
op.create_index(
"uq_family_adoptions_pair", "family_adoptions", ["project_id", "idea_id"], unique=True,
)
op.create_index("ix_family_adoptions_project_id", "family_adoptions", ["project_id"])
op.create_index("ix_family_adoptions_idea_id", "family_adoptions", ["idea_id"])
op.create_table(
"family_decisions",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column("idea_id", sa.Integer(),
sa.ForeignKey("family_ideas.note_id", ondelete="CASCADE"), nullable=False),
sa.Column("project_id", sa.Integer(),
sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=True),
sa.Column("action", sa.Text(), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("before", postgresql.JSONB(), nullable=True),
sa.Column("after", postgresql.JSONB(), nullable=True),
sa.Column("evidence", postgresql.JSONB(), nullable=True),
sa.Column("precedent_ids", postgresql.JSONB(), nullable=False,
server_default=sa.text("'[]'::jsonb")),
sa.Column("decided_via", sa.Text(), nullable=False, server_default="agent"),
sa.Column("user_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
op.create_check_constraint(
"ck_family_decisions_action", "family_decisions", _in("action", _ACTIONS),
)
op.create_check_constraint(
"ck_family_decisions_decided_via", "family_decisions", _in("decided_via", _DECIDERS),
)
# The reason IS the record — the agent decides with no approval step, so
# a decision that cannot say why is not one anybody can review or follow.
op.create_check_constraint(
"ck_family_decisions_reason", "family_decisions", "length(btrim(reason)) > 0",
)
op.create_index("ix_family_decisions_idea_id", "family_decisions", ["idea_id"])
op.create_index("ix_family_decisions_project_id", "family_decisions", ["project_id"])
def downgrade() -> None:
op.drop_index("ix_family_decisions_project_id", table_name="family_decisions")
op.drop_index("ix_family_decisions_idea_id", table_name="family_decisions")
op.drop_table("family_decisions")
op.drop_index("ix_family_adoptions_idea_id", table_name="family_adoptions")
op.drop_index("ix_family_adoptions_project_id", table_name="family_adoptions")
op.drop_index("uq_family_adoptions_pair", table_name="family_adoptions")
op.drop_table("family_adoptions")
op.drop_index("ix_family_idea_references_snippet_id", table_name="family_idea_references")
op.drop_table("family_idea_references")
op.drop_index("ix_family_idea_platforms_platform_id", table_name="family_idea_platforms")
op.drop_table("family_idea_platforms")
op.drop_index("ix_family_ideas_status", table_name="family_ideas")
op.drop_index("uq_family_ideas_topic", table_name="family_ideas")
op.drop_table("family_ideas")
op.drop_index("ix_project_platforms_platform_id", table_name="project_platforms")
op.drop_table("project_platforms")
op.drop_index("uq_platforms_slug", table_name="platforms")
op.drop_table("platforms")
@@ -0,0 +1,58 @@
"""retire the report_preference arm's rows
Revision ID: 0121
Revises: 0120
Create Date: 2026-10-09
The fixed-question arm that searched for completion-report preferences when a
task closed is gone: the reply shapes and the preferences mounted beside them
now arrive on the moments. Its rows go with it (there is no legacy to
preserve), because each one would otherwise mean something
different once the arm is no longer registered:
- `retrieval_logs` / `retrieval_judgments` — a source missing from the
registry reads as `unregistered_source`, whose advice is "add it to the
registry": the opposite of what happened.
- `rule_usage_events` — a source missing from `RANKED_SOURCES` reads as
ambient, so its surfacings would silently move into the other column of
every rule's pull-through.
- `retrieval_tuning_events` — the tuning history of a surface that no longer
exists.
- `settings` — the arm's floor and budget keys, which nothing reads.
COST IS BOUNDED BY AN INDEX, NOT BY THE TABLE. A migration runs at boot
against the live tables, which CI never has. `retrieval_logs`
and `retrieval_judgments` are indexed on `source`. `rule_usage_events` is not,
so its delete is also bounded by `created_at` (indexed): no row of the arm
predates `ARM_BORN`, and the scan covers weeks rather than the table's whole history.
Not reversible: the arm is not coming back, so neither are its rows.
"""
from alembic import op
revision = "0121"
down_revision = "0120"
branch_labels = None
depends_on = None
SOURCE = "report_preference"
# A safe floor under the arm's first row (it shipped after 2026-09-14).
ARM_BORN = "2026-09-01"
def upgrade() -> None:
op.execute(f"DELETE FROM retrieval_logs WHERE source = '{SOURCE}'")
op.execute(f"DELETE FROM retrieval_judgments WHERE source = '{SOURCE}'")
op.execute(
f"DELETE FROM rule_usage_events WHERE created_at >= '{ARM_BORN}' "
f"AND source = '{SOURCE}'"
)
op.execute(f"DELETE FROM retrieval_tuning_events WHERE surface = '{SOURCE}'")
op.execute(
"DELETE FROM settings WHERE key IN "
"('kb_reportpref_threshold', 'kb_reportpref_top_k')"
)
def downgrade() -> None:
pass
@@ -0,0 +1,82 @@
"""Seal forge-connection tokens at rest
Revision ID: 0122
Revises: 0121
Create Date: 2026-10-15
A forge token was stored as plain text, so a copy of the database (or of a
backup tool's snapshot of it) was enough to read every user's forge
credential. Tokens are now sealed the way the backup destination's S3 secret
is (services/secrets.py): Fernet under a key derived from SECRET_KEY, which
lives in the deployment's environment rather than in the data.
The column is renamed `token` -> `token_sealed` in the same step. The name
says what the bytes are, and every reader has to change with it, so a reader
missed here fails on a missing attribute instead of quietly sending
ciphertext to the forge as a token.
THE KEY DERIVATION IS FROZEN HERE, not imported. This revision must seal with
exactly v1's key forever; importing services.secrets would let a later edit
there change what an old migration writes. The unit lane checks that what
this produces is what `secrets.unseal` opens today.
Cost: one row per (user, forge host) — a handful per instance.
Downgrade opens each token back into plain text. A row sealed under a
different SECRET_KEY cannot be opened and comes back empty; its owner enters
the token again.
"""
import base64
import hashlib
import sqlalchemy as sa
from alembic import op
revision = "0122"
down_revision = "0121"
branch_labels = None
depends_on = None
_LABEL_V1 = b"scribe.secrets.v1\x00"
def _fernet():
from cryptography.fernet import Fernet
from grimoire.config import Config
digest = hashlib.sha256(_LABEL_V1 + Config.SECRET_KEY.encode()).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def seal_v1(plain: str) -> str:
return _fernet().encrypt(plain.encode()).decode()
def upgrade() -> None:
op.alter_column("forge_connections", "token", new_column_name="token_sealed")
bind = op.get_bind()
rows = bind.execute(sa.text("SELECT id, token_sealed FROM forge_connections")).all()
for row_id, plain in rows:
bind.execute(
sa.text("UPDATE forge_connections SET token_sealed = :v WHERE id = :id"),
{"v": seal_v1(plain or ""), "id": row_id},
)
def downgrade() -> None:
from cryptography.fernet import InvalidToken
bind = op.get_bind()
fernet = _fernet()
rows = bind.execute(sa.text("SELECT id, token_sealed FROM forge_connections")).all()
for row_id, sealed in rows:
try:
plain = fernet.decrypt((sealed or "").encode()).decode()
except (InvalidToken, ValueError):
plain = ""
bind.execute(
sa.text("UPDATE forge_connections SET token_sealed = :v WHERE id = :id"),
{"v": plain, "id": row_id},
)
op.alter_column("forge_connections", "token_sealed", new_column_name="token")
@@ -0,0 +1,64 @@
"""drop user_profiles
Revision ID: 0123
Revises: 0122
Create Date: 2026-10-22
The Profile settings page asked for a display name, job title, expertise,
response style, tone, interests and a work schedule, and said an assistant
used them to personalise chat and a daily journal. Nothing in the app read
any of it: the page saved to this table and the backup carried it, and that
was all. The page, its API and the backup section are gone, and the table
goes with them.
Downgrade recreates the empty table, so the schema steps back cleanly; the
rows themselves do not come back.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
revision = "0123"
down_revision = "0122"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_index("ix_user_profiles_user_id", table_name="user_profiles")
op.drop_table("user_profiles")
def downgrade() -> None:
op.create_table(
"user_profiles",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
unique=True,
),
sa.Column("display_name", sa.Text(), nullable=True),
sa.Column("job_title", sa.Text(), nullable=True),
sa.Column("industry", sa.Text(), nullable=True),
sa.Column("expertise_level", sa.Text(), nullable=True),
sa.Column("response_style", sa.Text(), nullable=True),
sa.Column("tone", sa.Text(), nullable=True),
sa.Column("interests", ARRAY(sa.Text()), nullable=True),
sa.Column("work_schedule", JSONB(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_user_profiles_user_id", "user_profiles", ["user_id"], unique=True)
@@ -0,0 +1,45 @@
"""skill names follow the plugin's name
Revision ID: 0124
Revises: 0123
Create Date: 2026-10-22
The plugin is named `grimoire`, so its skills reach a session as
`grimoire:<skill>` and stored processes as `grimoire-proc-<slug>`. A rule
mounted on one of those skills, a judgment about that mount, and an install's
own action mapping all store the skill's name, and a stored name the harness no
longer sends never matches again. This rewrites the plugin's earlier prefixes,
`scribe:` and `scribe-proc-`, to the current ones.
Cost: three small tables, each one row per mount, judgment or mapping.
"""
from alembic import op
revision = "0124"
down_revision = "0123"
branch_labels = None
depends_on = None
_MOMENT_TABLES = ("rule_moments", "rule_moment_judgments", "moment_mappings")
def _rename(old: str, new: str) -> None:
for table in _MOMENT_TABLES:
op.execute(
f"UPDATE {table} SET moment = regexp_replace(moment, "
f"'^skill\\.{old}(:|-proc-)', 'skill.{new}\\1') "
f"WHERE moment ~ '^skill\\.{old}(:|-proc-)'"
)
op.execute(
f'UPDATE moment_mappings SET "match" = regexp_replace("match", '
f"'skill={old}(:|-proc-)', 'skill={new}\\1', 'g') "
f"WHERE \"match\" ~ 'skill={old}(:|-proc-)'"
)
def upgrade() -> None:
_rename("scribe", "grimoire")
def downgrade() -> None:
_rename("grimoire", "scribe")
@@ -0,0 +1,35 @@
"""drop machine-written uses edges that cross projects
Revision ID: 0125
Revises: 0124
Create Date: 2026-10-26
A `uses` edge with basis `reference` is the proposer's by-name reading: the
shape's body names a canon's symbol. A body never calls a function defined in
another project's repository, so such an edge across projects is a common
word matched, not a call. The proposer no longer writes them, and this removes
the ones it wrote before. Edges a person or an audit judged keep their own
basis and are left alone. Rows within one project are re-read on the next
coverage refresh, which replaces their reference edges under the same rule.
Downgrade restores nothing: the removed edges were never facts.
"""
from alembic import op
revision = "0125"
down_revision = "0124"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"DELETE FROM code_shape_uses u USING code_shapes s, notes n "
"WHERE u.shape_id = s.id AND u.snippet_id = n.id "
"AND u.basis = 'reference' "
"AND n.project_id IS NOT NULL AND n.project_id <> s.project_id"
)
def downgrade() -> None:
pass
@@ -0,0 +1,34 @@
"""drop write-path uses edges that cross projects
Revision ID: 0126
Revises: 0125
Create Date: 2026-10-26
The write path records a `uses` edge with basis `hook` when a session pulled a
snippet and then wrote code naming its symbol. That is by-name evidence, like
the proposer's `reference` edges 0125 removed, and a body never calls a
function defined in another project's repository. The write path now applies
the same by-name test, so it writes no such edge; this removes the ones it
wrote before. Judged edges keep their own basis and are left alone.
Downgrade restores nothing: the removed edges were never facts.
"""
from alembic import op
revision = "0126"
down_revision = "0125"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"DELETE FROM code_shape_uses u USING code_shapes s, notes n "
"WHERE u.shape_id = s.id AND u.snippet_id = n.id "
"AND u.basis = 'hook' "
"AND n.project_id IS NOT NULL AND n.project_id <> s.project_id"
)
def downgrade() -> None:
pass
@@ -0,0 +1,39 @@
"""instance identity — the id lineage a backup carries
Revision ID: 0127
Revises: 0126
Create Date: 2026-11-05
One row naming this database's id lineage. Every full backup carries it, a
restore into a fresh instance adopts it (the restore keeps the original ids),
and rolling a live instance back requires the backup's to match, so a backup's
ids never land on unrelated rows. Made here with a random value, so every
instance has one before its first account exists.
"""
import sqlalchemy as sa
from alembic import op
revision = "0127"
down_revision = "0126"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"instance_identity",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("instance_id", sa.Text(), nullable=False),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now(),
),
sa.CheckConstraint("id = 1", name="ck_instance_identity_single_row"),
)
op.execute(
"INSERT INTO instance_identity (id, instance_id) VALUES (1, gen_random_uuid()::text)"
)
def downgrade() -> None:
op.drop_table("instance_identity")
+13 -23
View File
@@ -1,4 +1,4 @@
# CI Requirements — FabledScribe
# CI Requirements — FabledGrimoire
> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
> in the CI-Runner repo.
@@ -16,11 +16,10 @@ real Postgres), build (docker buildx).
## Image deps used
- python 3.14
- node 24 (used for `npm ci` + `vue-tsc` in the typecheck job, and as the
frontend builder stage inside the production `Dockerfile`)
- ruff (lint job runs `ruff check src/` with zero install overhead)
- uv (test + integration jobs run `uv sync --locked`; installed in the
image since the ci-python Dockerfile started pip-installing it)
- node 24 (`npm ci` + `vue-tsc` in the typecheck job; the production
`Dockerfile` builds the frontend on `node:22-alpine`)
- ruff (lint job runs `ruff check src/ scripts/` with zero install overhead)
- uv (test + integration jobs run `uv sync --locked`)
- docker CLI + buildx (build job pushes the production image to the
Fabled-Git registry)
@@ -30,24 +29,15 @@ Anything CI installs at job time that isn't in the image. Promotion
candidates if more than one project needs them.
- `shellcheck` — apt-installed in the **plugin** job, which lints the
Claude Code hook scripts and runs their fail-open smoke test. Per
`docs/process.md`'s decision checkpoint, single-consumer deps stay
per-job until a second consumer wants them; Scribe is the only one so
far. It is small (~20 MB) and would be a promotion candidate the moment
another project lints shell.
Claude Code hook scripts and runs their fail-open smoke test. Grimoire is
its only consumer, so per `docs/process.md` it stays per-job. It is small
(~20 MB) and a promotion candidate once another project lints shell.
- `jq` — **no longer installed anywhere, and should not be promoted.**
It was installed in two jobs, and this file used to record it as "load-
bearing for the smoke test specifically", because every hook opened
`command -v jq || exit 0` and the test would otherwise pass while
exercising nothing. That was the tail wagging the dog: the hooks ship to
users, jq is absent by default on macOS, the Debian/Ubuntu slim images,
Alpine and most CI containers, and a machine without it got no context,
no rules, no prior art and no process sync in silence. #4107 removed the
dependency rather than documenting it, so the smoke test now runs on a
bare image — which is the condition it was always meant to assert. The
hooks use only POSIX tools (awk, sed, tr, od, cut, head, tail, grep,
sort, date, printf) plus `git` and `curl`.
The hooks ship to users' machines, so they use only POSIX tools (awk, sed,
tr, od, cut, head, tail, grep, sort, date, printf) plus `git` and `curl`.
The smoke test runs on a bare image to keep it that way: a tool such as `jq`
is missing on macOS, slim Debian/Ubuntu and Alpine, and a hook that needed it
would give those machines nothing.
## Notes
+35
View File
@@ -0,0 +1,35 @@
# Grimoire built from this checkout, for development:
# docker compose -f docker-compose.dev.yml up --build
services:
app:
build: .
ports:
- "5000:5000"
depends_on:
- db
restart: unless-stopped
volumes:
- app_data:/data
# Or a bind mount, for direct host access to the app's data:
# - ./data:/data
environment:
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-grimoire}:${POSTGRES_PASSWORD:-grimoire}@db:5432/${POSTGRES_DB:-grimoire}"
# Optional: a SearXNG instance to list as the web-search backend in Integrations.
# SEARXNG_URL: "http://searxng:8080"
db:
# Postgres 17 with the vector extension Grimoire needs.
image: pgvector/pgvector:pg17
stop_grace_period: 120s
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: ${POSTGRES_USER:-grimoire}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-grimoire}
POSTGRES_DB: ${POSTGRES_DB:-grimoire}
volumes:
pgdata:
app_data:
+18 -38
View File
@@ -1,59 +1,39 @@
services:
app:
image: git.fabledsword.com/bvandeusen/fabledscribe:latest
image: git.fabledsword.com/bvandeusen/fabledgrimoire:latest
environment:
DATABASE_URL: "postgresql+asyncpg://scribe:${DB_PASSWORD}@db:5432/scribe"
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-grimoire}:${DB_PASSWORD}@db:5432/${POSTGRES_DB:-grimoire}"
# A Swarm can start the app on another node, away from the key kept in
# a node-local /data volume, so a stack sets its key itself (or mounts
# a Docker secret and sets SECRET_KEY_FILE to its path).
SECRET_KEY: "${SECRET_KEY}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
TRUST_PROXY_HEADERS: "true"
SECURE_COOKIES: "true"
# Proxies in front of the app (Traefik = 1). The client address and
# HTTPS are read from that far in from the right of X-Forwarded-*.
TRUSTED_PROXY_HOPS: "1"
volumes:
- app_data:/data
networks:
- scribe_backend
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
deploy:
restart_policy:
condition: on-failure
max_attempts: 5
- backend
db:
# pgvector image (Debian/glibc, PG17) — bundles the `vector` extension that
# migration 0067 enables. Moved off postgres:16-alpine via logical
# dump/restore (which doubles as the PG16->PG17 major upgrade); see the
# TRANSITION runbook in the PR.
# Postgres 17 with the vector extension Grimoire needs.
image: pgvector/pgvector:pg17
stop_grace_period: 120s
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: scribe
POSTGRES_USER: ${POSTGRES_USER:-grimoire}
POSTGRES_PASSWORD: "${DB_PASSWORD}"
POSTGRES_DB: scribe
POSTGRES_DB: ${POSTGRES_DB:-grimoire}
networks:
- scribe_backend
# Lenient by design: a transient host exec/healthcheck stall (incident:
# runc setns failures -> "unhealthy" -> SIGKILL -> crash loop) must never
# escalate to killing the DB. Health here only gates app startup order.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U scribe"]
interval: 30s
timeout: 10s
retries: 10
start_period: 180s
deploy:
restart_policy:
condition: on-failure
delay: 10s
max_attempts: 0
window: 120s
- backend
volumes:
pgdata:
name: ${PGDATA_VOLUME:-grimoire_pgdata}
app_data:
networks:
scribe_backend:
backend:
driver: overlay
-59
View File
@@ -1,59 +0,0 @@
# Fabled Scribe — Quick Start
#
# No build required. Pulls the latest pre-built image from the registry.
#
# Usage:
# 1. Download this file
# 2. docker compose -f docker-compose.quickstart.yml up -d
# 3. Open http://localhost:5000 — the first account registered becomes admin
# 4. Go to Settings → MCP Access and connect Claude (Code or Desktop) via the
# bearer-token URL shown there.
#
# Set SECRET_KEY via environment variable or a .env file alongside this file:
# SECRET_KEY=your-random-secret-here
services:
app:
image: git.fabledsword.com/bvandeusen/fabledscribe:latest
ports:
- "5000:5000"
environment:
DATABASE_URL: "postgresql+asyncpg://scribe:scribe@db:5432/scribe"
SECRET_KEY: "${SECRET_KEY:-change-me-in-production}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
volumes:
- app_data:/data
depends_on:
db:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
db:
# pgvector image (PG17) — bundles the `vector` extension (migration 0067).
image: pgvector/pgvector:pg17
stop_grace_period: 120s
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: scribe
POSTGRES_PASSWORD: scribe
POSTGRES_DB: scribe
# Lenient by design: a transient host exec/healthcheck stall must never
# escalate to killing the DB. Health here only gates app startup order.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U scribe"]
interval: 30s
timeout: 10s
retries: 10
start_period: 180s
restart: unless-stopped
volumes:
app_data:
pgdata:
+25 -32
View File
@@ -1,47 +1,40 @@
# Fabled Grimoire: the app and its database.
# docker compose up -d
# Then open http://localhost:5000. The first account registered becomes admin.
#
# Keep both volumes. app_data holds the instance's own SECRET_KEY, which seals
# saved credentials; pgdata holds everything else.
services:
app:
build: .
image: git.fabledsword.com/bvandeusen/fabledgrimoire:latest
ports:
- "5000:5000"
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-grimoire}:${POSTGRES_PASSWORD:-grimoire}@db:5432/${POSTGRES_DB:-grimoire}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
# Port 5000 is published straight to clients, so no proxy is trusted.
# Behind a reverse proxy, set 1 (one per proxy) and BASE_URL:
TRUSTED_PROXY_HOPS: "0"
# BASE_URL: "https://grimoire.example.com"
volumes:
- app_data:/data
# To use a bind mount instead (gives direct host access to all app data):
# - ./data:/data
environment:
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-scribe}:${POSTGRES_PASSWORD:-scribe}@db:5432/${POSTGRES_DB:-scribe}"
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
# Uncomment if you have a SearXNG instance you want to surface in the
# Integrations tab as a configured web-search backend:
# SEARXNG_URL: "http://searxng:8080"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
depends_on:
- db
restart: unless-stopped
db:
image: postgres:16-alpine
# Postgres 17 with the vector extension Grimoire needs.
image: pgvector/pgvector:pg17
stop_grace_period: 120s
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: ${POSTGRES_USER:-scribe}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scribe}
POSTGRES_DB: ${POSTGRES_DB:-scribe}
# Lenient by design: a transient host exec/healthcheck stall must never
# escalate to killing the DB. Health here only gates app startup order.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scribe}"]
interval: 30s
timeout: 10s
retries: 10
start_period: 180s
POSTGRES_USER: ${POSTGRES_USER:-grimoire}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-grimoire}
POSTGRES_DB: ${POSTGRES_DB:-grimoire}
restart: unless-stopped
volumes:
pgdata:
app_data:
pgdata:
-271
View File
@@ -1,271 +0,0 @@
# Fable MCP — Design Spec
**Date:** 2026-03-23
**Status:** Approved
**Author:** bvandeusen + Claude
---
## Overview
A Python MCP (Model Context Protocol) server that lets Claude directly interface with a running Fabled Assistant instance. The goal is two-fold: Claude's stronger reasoning handles planning, documentation, and analysis while Fable remains the authoritative store for notes, tasks, projects, and milestones; and direct API access enables process improvement by letting Claude observe and operate on real data rather than working from descriptions.
The work is split into two sub-projects:
1. **Fable API Key Feature** — additions to the main `scribe` project to support bearer token authentication
2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root
A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working.
---
## Sub-project 1: Fable API Key Feature
### Motivation
All existing Fable routes use session-based authentication (`session["user_id"]`). The MCP server runs as a local process and cannot maintain a browser session, so a stateless bearer token mechanism is required. API keys are user-scoped and carry a read/write permission level.
### Database
New table `api_keys` via migration `0027_add_api_keys.py`:
- `down_revision = "0026"` (the briefing tables migration)
- `revision = "0027"`
| Column | Type | Notes |
|--------|------|-------|
| `id` | Integer PK | |
| `user_id` | Integer FK → users | CASCADE delete |
| `name` | Text | Human-readable label |
| `key_hash` | Text | SHA-256 of the full key, used for lookup |
| `key_prefix` | Text | First 8 chars (e.g. `fmcp_ab12`), shown in UI |
| `scope` | Text | `"read"` or `"write"` |
| `last_used_at` | Timestamp | Nullable, updated on each authenticated request |
| `created_at` | Timestamp | |
| `revoked_at` | Timestamp | Nullable — soft delete |
Full keys are generated as `fmcp_<32 random url-safe chars>`, returned once at creation, never stored. Only the SHA-256 hash is persisted.
### Auth Middleware (`auth.py`)
`_check_auth` is updated to check the `Authorization: Bearer <token>` header before falling back to session auth:
1. If header present: hash the token, look up in `api_keys` where `revoked_at IS NULL`
2. If found: update `last_used_at`, set `g.user` and `g.api_key`
3. Scope enforcement: if `g.api_key.scope == "read"` and `request.method` is not `GET`, return 403
4. If header absent: existing session logic runs unchanged
Session auth is untouched — no regression risk for the web UI.
**Admin routes and API keys:** Routes decorated with `admin_required` check `user.role == "admin"` after auth resolves. API keys authenticate as the key owner — so a write-scoped key for a non-admin user will fail admin-protected routes with 403 (role check, not scope check). This is intentional: API keys cannot elevate privilege beyond the user's role.
### Routes (`/api/api-keys`)
New blueprint `api_keys_bp`, registered in `app.py`:
- `GET /api/api-keys` — list caller's keys (prefix, name, scope, last_used_at, created_at — never the hash or full key)
- `POST /api/api-keys` — create key; body: `{name, scope}`. Returns full key in response **once only**
- `DELETE /api/api-keys/:id` — revoke by setting `revoked_at = now()`
### Settings UI
New "API Keys" tab in `SettingsView.vue` (added to `VALID_TABS`):
- Create form: name input + read/write radio/toggle + "Generate Key" button
- After creation: one-time modal displaying the full key with a copy button and a warning that it will not be shown again
- Keys table: columns for name, scope badge, prefix, last used, revoke button
- Revoke shows inline confirmation before calling DELETE
### New Search Endpoint
`GET /api/search?q=<query>&content_type=note|task|all&limit=N`
Calls the existing `semantic_search_notes()` service (already in `services/embeddings.py`). The `content_type` parameter maps to the service's `is_task` argument:
- `content_type=note` → `is_task=False`
- `content_type=task` → `is_task=True`
- `content_type=all` (default) → `is_task=None`
Returns:
```json
{
"results": [
{"id": 1, "title": "...", "body": "...", "is_task": false, "similarity": 0.87, "tags": [...]}
],
"total": 5
}
```
This endpoint is needed by the MCP's `search.py` tool module. It reuses existing infrastructure with no new ML work.
### Conversation Type: `"mcp"`
A new conversation type `"mcp"` is added alongside `"chat"` and `"briefing"`. This requires changes in two places in the main app:
**`services/chat.py`** — `create_conversation(user_id, title, model)` gains an optional `conversation_type: str = "chat"` parameter, passed through to the `Conversation` constructor.
**`routes/chat.py`** — `POST /api/chat/conversations` accepts an optional `conversation_type` body field (defaults to `"chat"`), passed to `create_conversation`. `GET /api/chat/conversations` continues to default-filter to `conversation_type="chat"` (excluding `"mcp"` and `"briefing"`); pass `?type=mcp` to retrieve MCP conversations.
**Retention:** `cleanup_old_conversations` in `routes/chat.py` currently deletes conversations regardless of type. It must be updated to exclude `conversation_type="mcp"` from the sweep, so MCP audit-trail conversations are not automatically pruned.
MCP conversations:
- Are created with a caller-supplied name (e.g., `"MCP Session 2026-03-23"`) or auto-named
- Are excluded from the default chat list
- Are accessible via `GET /api/chat/conversations?type=mcp` for inspection
- Appear in the Fable UI if explicitly navigated to, providing an audit trail of MCP-driven interactions
### Chat SSE Wire Format
The MCP's `chat.py` tool must consume the existing SSE stream. The relevant endpoints and event schema:
- **Create conversation:** `POST /api/chat/conversations` → `{id, title, ...}`
- **Post message + start generation:** `POST /api/chat/conversations/:id/messages` → `{message_id, ...}`; then connect to:
- **Stream:** `GET /api/chat/conversations/:id/stream` (SSE)
SSE event format (each line is `data: <json>`):
- `{"type": "token", "content": "..."}` — streaming token
- `{"type": "tool_call", "name": "...", "result": {...}}` — tool fired
- `{"type": "done", "content": "...", "tools_used": [...]}` — generation complete; this is the termination event
The MCP tool reads tokens until it receives `type: "done"`, then returns `response` (full content) and `tools_used` (list of tool names).
---
## Sub-project 2: Fable MCP Server
### Location
`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `scribe`. It will be extracted to its own Forgejo repo once stable.
### Package Structure
```
fable-mcp/
pyproject.toml # entry point: fable-mcp = "fable_mcp.server:main"
README.md
.env.example # FABLE_URL=http://localhost:8080, FABLE_API_KEY=fmcp_...
fable_mcp/
__init__.py
server.py # FastMCP instance, imports + registers all tool modules
client.py # FableClient: async httpx wrapper, env var config, FableAPIError
tools/
__init__.py
notes.py # list_notes, get_note, create_note, update_note, delete_note
tasks.py # list_tasks, get_task, create_task, update_task, delete_task,
# patch_task_status, add_task_log
projects.py # list_projects, get_project, create_project, update_project,
# delete_project, get_project_summary
milestones.py # list_milestones, get_milestone, create_milestone,
# update_milestone, delete_milestone
search.py # semantic_search — calls GET /api/search
chat.py # send_message — creates/continues conversation, returns response
```
### Dependencies
```toml
[project]
dependencies = [
"mcp[cli]>=1.0",
"httpx>=0.27",
"python-dotenv>=1.0",
]
```
### `client.py`
`FableClient` is an async context manager wrapping `httpx.AsyncClient`. It reads `FABLE_URL` and `FABLE_API_KEY` from environment variables (with `python-dotenv` fallback to `.env`). All methods are `async def` and return parsed dicts. A `FableAPIError(status_code, message)` exception is raised for any non-2xx response.
A module-level singleton `_client: FableClient` is initialized at server startup and shared across all tool modules.
### `server.py`
Uses `mcp[cli]`'s `FastMCP` class. Imports all tool modules, which register their tools via decorators against the shared `FastMCP` instance. Entry point `main()` calls `mcp.run()` (stdio transport).
### Tool Modules
Each module imports `_client` from `client.py` and defines tools as `async def` functions decorated with `@mcp.tool()`. Tool docstrings serve as the MCP tool descriptions visible to Claude.
**`notes.py`** tools:
- `list_notes(query, tags, project_id, limit, offset)`
- `get_note(note_id)`
- `create_note(title, body, tags, project_id)`
- `update_note(note_id, title, body, tags, project_id)`
- `delete_note(note_id)`
**`tasks.py`** tools:
- `list_tasks(query, project_id, milestone_id, status, priority, limit, offset)`
- `get_task(task_id)`
- `create_task(title, body, project_id, milestone_id, priority, status)`
- `update_task(task_id, title, body, project_id, milestone_id, priority, status)`
- `delete_task(task_id)`
- `patch_task_status(task_id, status)`
- `add_task_log(task_id, content)`
**`projects.py`** tools:
- `list_projects()`
- `get_project(project_id)` — includes milestone summary
- `create_project(title, description, goal, color)`
- `update_project(project_id, title, description, goal, status, color)`
- `delete_project(project_id)`
**`milestones.py`** tools:
- `list_milestones(project_id, status)`
- `get_milestone(project_id, milestone_id)`
- `create_milestone(project_id, title, description, order_index)`
- `update_milestone(project_id, milestone_id, title, description, status, order_index)`
- `delete_milestone(project_id, milestone_id)`
**`search.py`** tools:
- `semantic_search(query, content_type, limit)` — `content_type` is `"note"`, `"task"`, or `"all"` (avoids shadowing Python's `type` builtin). Calls `GET /api/search`, returns ranked results with similarity scores.
**`chat.py`** tools:
- `send_message(message, conversation_name)`:
- Looks up or creates a Fable conversation with the given name and `conversation_type="mcp"`
- Posts the message via `POST /api/chat/conversations/:id/messages`
- Consumes SSE stream from `GET /api/chat/conversations/:id/stream` until `type: "done"`
- Returns: `{response: str, tools_used: [str], conversation_id: int}`
- MCP conversations are excluded from the normal chat UI list
### Error Handling
`FableAPIError` is caught at each tool boundary and returned as a descriptive string rather than propagating as an exception. This ensures Claude receives a readable error message (e.g., `"Fable API error 403: Read-only key cannot perform write operations."`) rather than a stack trace. Network errors (`httpx.RequestError`) are similarly caught and surfaced as strings.
Scope enforcement lives entirely in Fable's auth middleware — the MCP does not duplicate it.
### Claude Code Registration
After `pip install -e fable-mcp/` (or `uv tool install ./fable-mcp`), add to `~/.claude/settings.json`:
```json
{
"mcpServers": {
"fable": {
"command": "fable-mcp",
"env": {
"FABLE_URL": "http://localhost:8080",
"FABLE_API_KEY": "fmcp_your_key_here"
}
}
}
}
```
Claude Code spawns the process over stdio automatically. No Docker, no daemon.
---
## Build & Repo Plan
1. Implement and test within `scribe/fable-mcp/`
2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`)
3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready
---
## Out of Scope (v1)
- MCP resources (browsable URI tree) — tools cover all use cases in Claude Code
- Forgejo MCP — follow-on spec
- Rate limiting on API key endpoints
- Key expiry / TTL
- Per-resource scope (e.g., key restricted to one project)
File diff suppressed because it is too large Load Diff
-200
View File
@@ -1,200 +0,0 @@
# Speech-to-Speech (S2S) Design Spec
**Branch:** `feature/voice-s2s`
**Date:** 2026-03-29
**Status:** Approved for implementation
---
## Decisions
- **STT:** faster-whisper (in-process Python, model size configurable via `STT_MODEL` env var)
- **TTS:** Kokoro TTS (in-process Python, voice/speed/style configurable per-user)
- **Input mode:** Push-to-talk (Phase 1); VAD deferred
- **No browser STT/TTS fallbacks** — self-hosted only, data stays on-server
- **No Android app** — web only for this implementation
---
## New Env Vars (`config.py`)
| Var | Default | Description |
|---|---|---|
| `VOICE_ENABLED` | `false` | Feature flag — opt-in |
| `STT_BACKEND` | `faster-whisper` | Only supported value currently |
| `STT_MODEL` | `base.en` | `tiny.en` / `base.en` / `small.en` / `medium.en` |
| `TTS_BACKEND` | `kokoro` | Only supported value currently |
---
## Per-User Settings (stored in `settings` table)
| Key | Default | Description |
|---|---|---|
| `voice_tts_voice` | `af_heart` | Kokoro voice ID |
| `voice_tts_speed` | `1.0` | Speech rate, 0.7–1.3 |
| `voice_speech_style` | `conversational` | `conversational` / `concise` / `detailed` |
---
## New Backend Files
### `src/scribe/services/stt.py`
Lazy singleton `WhisperModel` loader. Public API:
- `load_stt_model()` — called at startup via `asyncio.create_task`
- `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text
- `stt_available() -> bool`
### `src/scribe/services/tts.py`
Lazy singleton `KPipeline` loader. Public API:
- `load_tts_model()` — called at startup
- `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono)
- `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels
- `tts_available() -> bool`
### `src/scribe/routes/voice.py`
Blueprint at `/api/voice`, all routes `@login_required`.
| Endpoint | Method | Description |
|---|---|---|
| `/api/voice/status` | GET | STT/TTS availability; `enabled` false if `VOICE_ENABLED=false` |
| `/api/voice/voices` | GET | List available Kokoro voices |
| `/api/voice/transcribe` | POST | multipart `audio` field → `{"transcript": "...", "duration_ms": 123}` |
| `/api/voice/synthesise` | POST | `{"text", "voice", "speed"}` → WAV bytes |
---
## Modified Backend Files
### `src/scribe/app.py`
- Register `voice_bp` blueprint
- In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED`
### `src/scribe/config.py`
- Add 4 new env var attributes
- Add validation in `validate()`
### `src/scribe/services/llm.py`
- Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()`
- When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."*
- Append style modifier based on `voice_speech_style`
### `src/scribe/services/generation_task.py`
- Add `voice_mode: bool = False` to `run_generation()`
- Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()`
### `src/scribe/routes/chat.py`
- Allow `"voice"` in `conversation_type` whitelist
### `src/scribe/services/chat.py`
- Exclude `conversation_type == "voice"` from auto-cleanup retention
---
## New Frontend Files
### `frontend/src/composables/useVoiceRecorder.ts`
Wraps `MediaRecorder`. Exports: `recording`, `error`, `isSupported`, `startRecording()`, `stopRecording() -> Promise<Blob>`.
### `frontend/src/composables/useVoiceAudio.ts`
Wraps `AudioContext`. Exports: `playing`, `isSupported`, `play(blob)`, `stop()`.
### `frontend/src/components/VoiceOverlay.vue`
Floating PTT button (fixed bottom-right). Creates/reuses a `"voice"` conversation. Full flow: record → transcribe → send → stream → synthesise → play. Space bar hotkey (from `App.vue`). Mounted globally in `App.vue`.
---
## Modified Frontend Files
### `frontend/src/api/client.ts`
Add: `transcribeAudio(blob)`, `synthesiseSpeech(text, voice?, speed?)`, `getVoiceStatus()`, `getVoiceList()`
### `frontend/src/views/BriefingView.vue`
- "Listen" button: reads latest assistant message aloud via TTS
- Mic button in input bar: PTT → transcribe → auto-fill input → send
- Auto-TTS on assistant response when in listen mode
### `frontend/src/views/SettingsView.vue`
- New "Voice" tab: voice dropdown, speed slider, speech style radio
- Loads from `/api/settings`, saves via `PUT /api/settings`
### `frontend/src/App.vue`
- Mount `<VoiceOverlay />`
- Space bar → `"voice:ptt-toggle"` custom event
---
## Audio Format
| Direction | Format | Rationale |
|---|---|---|
| Browser → Server | WebM/Opus | Native `MediaRecorder` output; no re-encoding |
| Server → Browser | WAV (24kHz, 16-bit mono) | Kokoro native; no re-encoding; `decodeAudioData` compatible |
---
## Dependencies to Add (`pyproject.toml`)
```toml
[project.optional-dependencies]
voice = [
"faster-whisper>=1.0",
"kokoro>=0.9",
"soundfile>=0.12",
]
```
Install unconditionally in Docker (activated by `VOICE_ENABLED` at runtime):
```dockerfile
RUN pip install faster-whisper kokoro soundfile
```
---
## Database Migration
No schema changes required. `conversation_type` is unconstrained TEXT. Voice settings use existing key-value `settings` table. Optional no-op migration `0034_voice_conversation_type.py` for audit trail.
---
## Implementation Phases
### Phase 1 — Backend services + routes
1. Add env vars to `config.py`
2. Create `services/stt.py` (faster-whisper)
3. Create `services/tts.py` (Kokoro)
4. Create `routes/voice.py` (4 endpoints)
5. Wire model loading into `app.py` startup
6. Add `voice_mode` to `build_context()` + `run_generation()`
7. Allow `"voice"` conversation type in chat route + cleanup exclusion
### Phase 2 — BriefingView listen + voice follow-up
1. Create `useVoiceRecorder.ts`
2. Create `useVoiceAudio.ts`
3. Add voice API functions to `client.ts`
4. Add "Listen" button + mic button to `BriefingView.vue`
### Phase 3 — VoiceOverlay for general voice chat
1. Create `VoiceOverlay.vue`
2. Mount in `App.vue` + Space bar hotkey
### Phase 4 — Settings UI
1. Add "Voice" tab to `SettingsView.vue`
---
## Kokoro Voice Reference
| ID | Character |
|---|---|
| `af_heart` | American female, warm (recommended default) |
| `af_bella` | American female, expressive |
| `af_nicole` | American female, breathy/intimate |
| `af_sarah` | American female, clear |
| `af_sky` | American female, bright |
| `am_adam` | American male, neutral |
| `am_michael` | American male, deeper |
| `bf_emma` | British female |
| `bf_isabella` | British female, formal |
| `bm_george` | British male |
| `bm_lewis` | British male, casual |
-216
View File
@@ -1,216 +0,0 @@
# Agentic Briefing — Design Spec
**Date:** 2026-04-10
**Status:** Proposed
**Author:** bvandeusen + Claude
---
## Problem
The current briefing pipeline hallucinates calendar events, tasks, and news items that do not exist in the user's actual data. Observed examples from production:
- A morning briefing asserting "your dentist appointment is still in progress" when no such event existed
- An 8am check-in mentioning "a quick meeting at 10:30 AM" with no backing calendar entry
- A midday check-in inventing "a team huddle at 2:30 PM" and "the Q2 budget draft due by Friday"
- The model calling `search_images` in response to a clarifying question about a fabricated meeting
These are not bugs in data retrieval — the data gathering code (`_gather_internal`, `_gather_external`) returns correct values. They are a structural consequence of how the briefing context is assembled.
## Root cause — the "no receipt" problem
The current `run_compilation` pipeline does this:
1. Python code gathers data (tasks, events, weather, news) from the database
2. Python formats the data into a structured text blob as the user-role message: `TODAY'S EVENTS: ...`, `DUE TODAY: ...`, etc.
3. The LLM is called **once** with `[system_prompt, user_prompt]` and produces prose
4. Only the prose reply is written to the conversation. **The underlying data is never persisted in the conversation history.**
When the user later chats in the briefing conversation, the chat endpoint loads the full conversation history — which contains the model's *prose* from earlier but not the data that prose was derived from. The model's own prior output becomes the only source of "truth" available for follow-up questions. If that prose asserted a fact (real or hallucinated), the model has no way to distinguish it from ground truth when generating the next reply, and it will double down.
Compounding factors:
- **Empty sections are silently omitted.** If `calendar_events` is empty, the user prompt contains no `TODAY'S EVENTS:` line at all. The model has no explicit "zero events" signal — combined with an imperative system prompt ("note calendar events and tasks"), it interprets the silence as "I should mention some" and fabricates.
- **Scheduled slot injections append synthetic turns.** `run_slot_injection` writes a fake `[Midday briefing update]` user message and the assistant reply into the persistent conversation. By evening, the chat history contains three separate briefings, each potentially with errors, all treated as equal-weight context on follow-up.
- **The `search_images` tool is available during briefing chat**, with a negative-instruction description ("Not for factual questions"). Small and mid-sized models frequently ignore negative guidance in tool descriptions and call the tool anyway.
## Solution — agentic briefing (the receipt model)
Replace the one-shot synthesis with a tool-call loop. The briefing is no longer "a text blob synthesized from pre-gathered data." It is **a scheduled agent run**: the LLM is given a system prompt, a curated set of read-only data tools, and a trigger ("generate the morning briefing"). The model must call tools to see what exists. Every tool call and tool result becomes part of the conversation history, where it lives as a permanent, structured receipt.
### Why this fixes the hallucination
When the model calls `list_events(today) → []`, that empty array is now a persistent message in the conversation. On a follow-up question like "what meeting?", the chat endpoint loads the conversation and the model sees its own tool result from the morning showing no events. Answering "you had a meeting at 10:30" would require the model to directly contradict a tool result sitting two messages back — something LLMs are much more reliable at avoiding than contradicting their own prior prose.
In short: **the model cannot fabricate what has a visible receipt proving it does not exist.**
### What the model sees — before vs after
**Before** — the model sees one structured blob and produces prose. The blob is discarded.
```
[system] You are a personal assistant... weave together what matters...
[user] Date: 2026-04-10
WEATHER: Home — partly cloudy, 8–16°C
DUE TODAY: Fix briefing hallucinations
OVERDUE (2 tasks): ...
[assistant] Morning! Looks like a quiet day with two overdue items...
```
**After** — the model is given tools and iterates to ground truth.
```
[system] You are the user's personal assistant giving their morning briefing.
Use tools to see what's relevant. Only mention things you learned from
tool calls. If a tool returns nothing, say so honestly.
[user] Generate the morning briefing.
[assistant] tool_call: list_tasks(filter="due_today")
[tool] [{"id": 81, "title": "Fix briefing hallucinations", ...}]
[assistant] tool_call: list_tasks(filter="overdue")
[tool] [{"id": 42, ...}, {"id": 43, ...}]
[assistant] tool_call: list_events(today)
[tool] []
[assistant] tool_call: get_weather(location="home")
[tool] {"description": "partly cloudy", "temp_min": 8, "temp_max": 16}
[assistant] tool_call: get_rss_items(max=8)
[tool] [...]
[assistant] Morning! Partly cloudy today, 8 to 16 — nothing on the calendar
so it's a clean run at the desk. Two things to keep in mind...
```
The conversation now contains verifiable receipts: `list_events(today)` returned `[]`, and that result sits in context forever (until pruned). Follow-up questions operate against those receipts.
## Architecture
### Existing infrastructure (reused, not rebuilt)
The codebase already has the agentic primitives — they're used for regular chat:
- **`llm.py::stream_chat_with_tools`** — the streaming tool-use loop that talks to Ollama with a `tools` parameter and yields tool-call chunks
- **`generation_task.py::_stream_with_retry`** — wraps `stream_chat_with_tools` with retry-on-500 behavior for cold-model races
- **`tools.py`** — defines 40+ tool schemas and an execution dispatcher
The briefing bypasses all of this and calls a one-shot `_llm_synthesise` helper. The refactor is mainly "route briefings through the same pipeline regular chat already uses."
### New modules
**`briefing_tools.py`** — a small wrapper exposing a curated read-only subset of `tools.py` for briefing runs. This is an **explicit allowlist**, not a blocklist, so newly-added tools must be opted in:
| Tool | Purpose |
|---|---|
| `list_tasks` (with filter args) | See what's actionable today, overdue, high priority |
| `list_events` (today / upcoming) | Know what's on the calendar |
| `get_weather` | Current/forecast weather |
| `get_rss_items` | Pull news themes filtered by user preferences |
| `list_projects` | Understand project context |
| `search_projects` | Surface active project summaries |
| `list_notes` (recent) | Capture follow-ups from yesterday |
**Explicitly omitted** from the briefing tool set:
- `search_images`, `search_web`, `research_topic`, `read_article` — external search is not a briefing concern, and `search_images` is the source of the "Peter Kyle Science Secretary" image-search bug
- All `create_*`, `update_*`, `delete_*` — briefings are read-only; a scheduled background job must not decide to mutate the user's data on its own
- `set_rag_scope`, `calculate` — not relevant to briefing content
### New briefing function
**`briefing_pipeline.py::run_agentic_briefing(user_id, slot, model, conversation_id)`** replaces `run_compilation`'s body (and eventually `run_slot_injection`). Internally:
1. Build a slot-specific system prompt (see below)
2. Load the curated briefing tools from `briefing_tools.py`
3. Seed messages with `[system, user]` where the user message is a simple trigger like `"Generate the morning briefing."`
4. Enter a tool-call loop (max 8 iterations):
- Call `stream_chat_with_tools`
- If the model returns `tool_calls`, execute them via the existing dispatcher, append tool results, continue
- If the model returns a final assistant message with no pending tool calls, break
5. Return `(final_prose, full_message_list, metadata)`
The full message list is important: it's written to the conversation along with the final prose, so the tool-call receipts become part of the persistent record.
### Slot-specific system prompts
Compilation (full morning briefing):
```
You are the user's personal assistant giving their full morning briefing.
Use the tools available to see what's actually relevant today — tasks due,
overdue items, events on the calendar, weather, news themes, project state
— and weave it into a warm, natural-sounding summary.
Rules:
- Call tools to see the data. Never assert facts you didn't learn from a tool.
- If a tool returns nothing (no events today, no overdue tasks), say so
honestly. Don't fabricate items to fill space.
- Write flowing prose. No markdown, no headers, no bullet points.
- Aim for 6–10 sentences. Skip topics that have nothing interesting.
- Close on one or two concrete, actionable suggestions.
User profile (for tone and preferences):
{profile_body}
```
Check-ins (midday, afternoon):
```
You are the user's personal assistant giving a brief {slot} check-in.
Use tools to see what's changed since this morning. Focus on progress
and what's still unaddressed.
Rules:
- Call tools to see current state. Never assert facts without tool results.
- If nothing meaningful has changed, say so briefly — don't invent progress.
- 3–5 sentences, natural prose, no markdown.
```
### Conversation hygiene — removing the fake user messages
The current scheduler appends two fake messages on every slot injection:
```python
await post_message(conv.id, "user", f"[{slot.title()} briefing update]")
await post_message(conv.id, "assistant", text)
```
Under the agentic model, we drop the fake `"user"` message entirely. Slot updates become plain assistant messages tagged with `metadata.briefing_slot = "midday"`. The chat endpoint's message loader is updated to filter these when building the LLM context on follow-ups, so a user chatting in the briefing conversation doesn't see three earlier briefings smashed into their history. The UI continues to show them as visible timeline entries.
(This is the Option A filter from the earlier discussion — a small, surgical change compared to migrating briefings to a separate sidecar table. Sidecar storage remains a possible future step.)
### Ollama setup compatibility
The existing Ollama deployment uses non-parallel mode across two GPUs, with a concern about context duplication between cards. This is the correct setup for agentic briefings:
- Each tool-call iteration shares the same KV cache on the same GPU, so appending tool results is cheap
- There is no race where a second iteration could land on a different card with a different cache state
- The trade-off is serialization: a briefing in progress will block a concurrent user chat request until it finishes, but scheduled briefings are rare enough (4× per day) that this is acceptable
If GPU contention becomes a problem later, the right lever is pinning specific models to specific GPUs (e.g., background tasks on GPU 1, interactive models on GPU 0) — not enabling parallelism.
## Cost & trade-offs
- **Latency:** a briefing now makes 5–7 inference calls (one per tool-call decision plus the final prose) instead of 1. On a local Ollama with a 7B model, expect 15–40s per briefing vs the current 5–10s. Acceptable for a background job; if it becomes painful at the user-facing check-in slots, investigate letting the model batch independent tool calls in a single turn (Ollama supports this).
- **Model requirements:** the default model must be reliable at tool calling. `qwen2.5:7b`, `llama3.1:8b`, `mistral-small-3:24b`, and similar handle it well. Models in the ≤3B class typically fail — they either emit no tool calls and return empty prose, or hallucinate invalid tool arguments. If a user's `default_model` is too small, agentic mode should fall back to legacy mode with a log warning.
- **Context growth:** tool results bloat the conversation. At 8 tool calls per compilation × 4 slots per day, a daily briefing conversation can reach 20-30 KB of JSON-heavy history. Fine for a day; aged briefings should be archived/rolled up after 7 days.
- **Tool subset drift:** someone adds a new mutating tool to `tools.py` and forgets to update the briefing allowlist. Mitigated by the allowlist model — the default for new tools is "not exposed to briefings."
- **Infinite loop safety:** a buggy model could tool-call forever. Hard cap at 8 iterations, log a warning if hit, return whatever prose was last produced (or a fallback message).
## Migration path
Ship incrementally, each step independently reversible:
**PR 1 — Agentic compilation behind a feature flag.** Add `briefing_tools.py`, add `run_agentic_briefing`, add a per-user setting `briefing_mode: "legacy" | "agentic"` (default legacy). Route only the 4am compilation through the new path when the flag is set. Keep slot-injection on the legacy path. Enable the flag on the author's account first, validate output quality over several days, then flip the default for all users. No DB migration required — the setting lives in the existing `settings` table.
**PR 2 — Agentic slot injections + conversation hygiene.** Migrate midday/afternoon check-ins to the same pipeline. Remove the fake `[Midday briefing update]` user-role message; slot updates become plain assistant messages tagged with `metadata.briefing_slot`. Add a chat-message-loader filter that excludes slot-tagged messages from the LLM context on follow-ups (they remain visible in the UI).
**PR 3 — UI polish.** Collapsed tool-call status row in the briefing card ("✓ checked calendar · ✓ looked at tasks · ✓ pulled weather"), expanding to show tool results on click. Tool-result cards (weather, news, task list) rendered inline where useful.
**PR 4 (optional) — Sidecar storage for briefing snapshots.** If the chat-filter approach in PR 2 feels too hacky, migrate briefings out of the conversations table and into a dedicated `briefing_snapshots` table. Frontend renders them as pinned timeline cards separate from chat. Larger refactor; defer until PR 1–3 prove the approach works.
## Secondary win — tightening the main chat system prompt
The regular chat is already agentic (it uses `stream_chat_with_tools`), but its system prompt does not explicitly require the model to ground factual claims in tool results. The prompt discipline introduced for briefings — *"Never assert facts you didn't learn from a tool. If a tool returns nothing, say so honestly."* — is worth applying to the main chat system prompt in a follow-up PR. The mechanism already works; only the framing needs tightening.
## Out of scope
- The Android Flutter client's failure to render `search_images` tool-result cards. This is a separate rendering gap in the mobile app and does not affect the server-side fix. Tracked separately.
- Re-evaluating the Ollama parallelism setting. The current non-parallel config is correct for this work.
- Replacing the background model for title/summary/observation extraction. That model's role is unrelated to briefings.
+38 -24
View File
@@ -1,8 +1,8 @@
# API Keys and Scribe MCP
# API Keys and Grimoire MCP
## API Keys
API keys let external tools access your Fable data without a browser session. Each key is scoped to a single user — it can only access data that user owns or has been shared with them.
API keys let external tools access your Grimoire data without a browser session. Each key is scoped to a single user — it can only access data that user owns or has been shared with them.
### Scopes
@@ -15,40 +15,51 @@ Admin-level operations (log access, user management) require a `write`-scoped ke
### Creating a Key
1. Go to **Settings → API Keys**
1. Go to **Settings → MCP Access**
2. Enter a name (e.g. "Claude MCP", "Home Server")
3. Choose scope
4. Click **Generate Key**
5. Copy the key immediately — it is shown only once (the token is `fmcp_`-prefixed)
Paste the key into the `Authorization: Bearer <key>` header of your MCP client
config (see **Scribe MCP Server** below).
config (see **Grimoire MCP Server** below).
### Revoking a Key
Click **Revoke** next to the key in the API Keys table and confirm. Revoked keys are deleted immediately.
Click **Revoke** next to the key in the keys table and confirm. Revoked keys are deleted immediately.
---
## Scribe MCP Server
## Grimoire MCP Server
Scribe exposes itself as a set of MCP tools that Claude (and other MCP clients)
Grimoire exposes itself as a set of MCP tools that Claude (and other MCP clients)
can use to read and write your notes, tasks, projects, rulebooks, and more. The
server is **built into the app** — it is mounted as a streamable-HTTP endpoint
at **`/mcp`** on the running Scribe instance (`src/scribe/mcp/server.py`). There
at **`/mcp`** on the running Grimoire instance (`src/grimoire/mcp/server.py`). There
is nothing to install: no wheel, no separate package, no CLI. You connect a
client straight to the URL with a Bearer token.
### Authentication
Authenticate with an API key generated from **Settings → API Keys** (see above),
Authenticate with an API key generated from **Settings → MCP Access** (see above),
sent as `Authorization: Bearer fmcp_<key>`. A `read`-scoped key may call only the
read tools (`get_*`, `list_*`, `search`, `enter_project`, `retrieval_telemetry`);
any write/delete tool is rejected with `403`. The allow-list is explicit rather
than derived from the name — see `_READ_ONLY_TOOLS`, which is why the two reads
without a read-shaped name are spelled out here. A `write`-scoped key may call everything.
### Claude Code (Project-scoped)
### Claude Code: the plugin (recommended)
The plugin connects the MCP server and adds the hooks and skills that put Grimoire's records in front of Claude as it works. Install it from this repo:
```
/plugin marketplace add https://git.fabledsword.com/bvandeusen/FabledGrimoire.git
/plugin install grimoire@grimoire-plugin
```
It asks for your Grimoire base URL, an API key (kept in your OS keychain) and, optionally, a project id. Settings → MCP Access shows the exact command for your instance. [`plugin/README.md`](../plugin/README.md) describes each hook.
### Claude Code without the plugin
Add a `.mcp.json` at the project root. The server `type` is `http` and the URL is
your instance's `/mcp` endpoint:
@@ -56,9 +67,9 @@ your instance's `/mcp` endpoint:
```json
{
"mcpServers": {
"scribe": {
"grimoire": {
"type": "http",
"url": "https://your-scribe-instance.example.com/mcp",
"url": "https://your-grimoire-instance.example.com/mcp",
"headers": {
"Authorization": "Bearer fmcp_your-api-key"
}
@@ -78,23 +89,26 @@ specific project at a dev instance or an admin key.
### Available Tools
The tool surface is large (~70 tools) and evolves with the app, so the live
registration in **`src/scribe/mcp/tools/`** is the source of truth rather than a
table here. The tools are grouped by family:
There are well over a hundred tools and they change with the app, so the
registration in **`src/grimoire/mcp/tools/`** is the reference. By family:
| Family | Examples | Purpose |
|--------|----------|---------|
| Notes | `create_note`, `get_note`, `update_note`, `delete_note`, `list_notes` | Free-form knowledge |
| Tasks | `create_task`, `update_task`, `add_task_log`, `start_planning` | Actionable work + plans |
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
| Search / Recall | `search`, `get_recent`, `list_tags`, `retrieval_telemetry` | Semantic + structured recall, and the readout its thresholds are tuned from |
| Systems | `create_system`, `list_systems`, `list_system_records` | Reusable per-project subsystems/areas |
| Rulebooks | `list_rules`, `create_rule`, `create_project_rule`, `relate_rules`, … | Engineering/workflow rules |
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
| Notes | `create_note`, `get_note`, `update_note`, `create_records` | Knowledge, and several records that cite each other in one call |
| Tasks | `create_task`, `update_task`, `add_task_log` | Work, issues and spikes, and their logs |
| Projects / Milestones | `enter_project`, `start_planning`, `get_milestone` | Projects and their plans |
| Search / Recall | `search`, `what_might_apply`, `get_recent` | Recall by meaning, and the rules that may apply to an act |
| Systems | `create_system`, `list_system_records`, `map_system_to_canonical` | A project's areas |
| Rules | `create_rule`, `create_project_rule`, `relate_rules`, `rule_history`, `list_moments` | Rules, preferences, their relations and moments |
| Lessons | `create_lesson`, `judge_lesson_link` | Lessons and the rules they instance |
| Snippets / Shapes | `create_snippet`, `verify_snippet`, `classify_shapes`, `list_shapes` | Prior art and the shape ledger |
| Design systems | `resolve_design_system`, `get_design_system_stylesheet` | Tokens and guidance for UI work |
| Family canon | `list_family_ideas`, `assess_family_adoption` | Ideas shared across projects |
| Processes | `list_processes`, `get_process`, `create_process` | Stored procedures |
| Retrieval | `retrieval_telemetry`, `tune_retrieval` | What was surfaced, and the thresholds |
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
| Admin | `get_app_logs` (write/admin key) | Diagnostics |
Server-level usage guidance — when to reach for each entity, the
recall-before-acting reflex, and the rulebook conventions — is delivered to the
client automatically via the MCP server's `instructions` block (defined in
`src/scribe/mcp/server.py`).
`src/grimoire/mcp/server.py`).
+104 -7
View File
@@ -52,6 +52,9 @@ endpoint at `/mcp`, not these REST routes.
| POST | `/api/notes/:id/versions/:vid/pin` | Pin a version |
| GET / PUT / DELETE | `/api/notes/:id/draft` | Unsaved-edit draft (restore across page loads) |
| GET | `/api/notes/graph` | Knowledge-graph nodes/edges |
| GET | `/api/notes/duplicates` | Near-duplicate notes or tasks, grouped |
| GET | `/api/notes/due-for-verification` | Notes that carry a check, oldest verification first |
| POST | `/api/notes/:id/verify` | Record that a note's check was run `{still_true}` |
## Tasks
@@ -86,7 +89,55 @@ endpoint at `/mcp`, not these REST routes.
| GET / POST | `/api/projects/:id/systems` | List / create systems |
| GET / PATCH / DELETE | `/api/projects/:id/systems/:sid` | Read / update / delete |
| GET | `/api/projects/:id/systems/:sid/records` | Records linked to a system |
| PUT | `/api/systems/:sid/canonical` | Map a system to a shared area `{canonical_id}` (`null` unmaps) |
| GET / POST | `/api/canonical-systems` | List / create shared areas |
| PATCH | `/api/canonical-systems/:id` | Update a shared area |
| GET | `/api/projects/:id/issues` | Project issues |
| PUT | `/api/projects/:id/design-system` | Point the project at a design system `{design_system_id}` (`null` clears) |
| GET / PUT | `/api/projects/:id/platforms` | The project's platforms `{platforms: {slug: "declared" \| "rejected" \| null}}` |
| PUT | `/api/projects/:id/forge` | Pin the project to one forge connection, or clear it |
| GET / POST | `/api/platforms` | Platform catalogue / add a platform |
| PATCH | `/api/platforms/:id` | Update a platform |
## Snippets and lessons
| Method | Path | Description |
|--------|------|-------------|
| GET / POST | `/api/snippets` | List / create snippets |
| GET / PATCH / DELETE | `/api/snippets/:id` | Read / update / delete |
| POST | `/api/snippets/:id/verify` | Record a drift-check verdict `{status, detail, path}` |
| GET | `/api/snippets/duplicates` | Near-duplicate snippets, grouped into merge candidates |
| POST | `/api/snippets/:id/merge` | Merge source snippets into this one |
| POST | `/api/snippets/:id/unmerge` | Pull one source back out `{source_id}` |
| GET / POST | `/api/lessons` | List / create lessons |
| GET / PATCH / DELETE | `/api/lessons/:id` | Read / update / delete (to the trash) |
| GET | `/api/lessons/rule-candidates` | Rules a draft lesson resembles, before it is saved |
| GET | `/api/lessons/taught-by/:record_id` | Lessons drawn from one record |
| PUT | `/api/lessons/:id/rules/:rule_id` | Confirm or reject a lesson → rule link |
## Design systems
| Method | Path | Description |
|--------|------|-------------|
| GET / POST | `/api/design-systems` | List / create |
| GET | `/api/design-systems/starter-roles` | Starter token roles for the creation form |
| GET / PATCH / DELETE | `/api/design-systems/:id` | Read / update / delete |
| GET | `/api/design-systems/:id/resolved` | The effective token set, inheritance applied |
| GET | `/api/design-systems/:id/stylesheet` | The generated stylesheet |
| GET | `/api/design-systems/:id/snippet-check` | Snippets that disagree with the system's sheet |
| GET / POST | `/api/design-systems/:id/tokens` | This system's own tokens / add one |
| PATCH / DELETE | `/api/design-tokens/:id` | Update / delete a token |
## Family canon
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/family/ideas` | Family ideas |
| GET | `/api/family/ideas/:id` | One idea with its references |
| POST | `/api/family/ideas/:id/propose` · `/promote` · `/retire` | Move an idea through its lifecycle |
| GET | `/api/family/matrix` | Projects × canon ideas, with each project's answer |
| GET | `/api/family/decisions` | Promotion and retirement decisions |
| POST | `/api/family/decisions/:id/undo` | Undo a decision |
## Knowledge browse
@@ -118,6 +169,27 @@ endpoint at `/mcp`, not these REST routes.
| POST | `/api/rules/:id/move` | Move a rule: `{topic_id}` makes it global, `{project_id}` makes it that project's |
| GET | `/api/projects/:id/rules` | A project's own rules, and the global rules tagged to its areas |
| POST | `/api/projects/:id/rules` | Create a project-scoped rule |
| GET | `/api/rules/:id/versions` | A rule's edit history, newest first |
| GET | `/api/rules/:id/versions/:vid` | One earlier version in full |
| POST | `/api/rules/:id/versions/:vid/restore` | Put a preference back to an earlier version (preferences only) |
| GET | `/api/rules/drift` | Preferences that have been rewritten, most recent first |
| POST | `/api/rules/:id/relations` | Relate this rule to another (`co_surfaces`, `overrides`, `elaborates`) |
| DELETE | `/api/rule-relations/:id` | Remove a relation |
| GET | `/api/rules-due-for-verification` | Rules that carry a check, oldest verification first |
| POST | `/api/rules/:id/verify` | Record that a rule's check was run `{still_true}` |
## Retrieval
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/retrieval/surfaces` | Every tunable surface with its threshold and budget |
| PUT | `/api/retrieval/surfaces/:surface` | Change one surface's setting, recorded with its reason |
| GET | `/api/retrieval/tuning-history` | Changes to retrieval on this install, newest first |
| GET | `/api/retrieval/moments` | The moments of work rules mount on, and the actions that reach each |
| POST / DELETE | `/api/retrieval/moments/mappings` | Map / unmap an action to a moment |
| GET | `/api/retrieval/moments/proposals` | Proposed rule → moment mounts awaiting a judgment |
| POST | `/api/retrieval/moments/proposals/judge` | Confirm or reject proposals |
| GET | `/api/retrieval/reply-shapes` | The default reply shapes and the moment each arrives at |
## Sharing
@@ -147,13 +219,15 @@ endpoint at `/mcp`, not these REST routes.
| POST | `/api/notifications/:id/read` | Mark one read |
| POST | `/api/notifications/read-all` | Mark all read |
## Profile and Settings
## Settings
| Method | Path | Description |
|--------|------|-------------|
| GET / PUT | `/api/profile` | Read / update the per-user profile |
| GET / PUT | `/api/settings` | Read / update key-value settings |
| GET | `/api/settings/search` | SearXNG configuration status |
| GET / POST | `/api/settings/forge-connections` | Your git forge connections / add one |
| PUT / DELETE | `/api/settings/forge-connections/:id` | Update / remove a connection |
| POST | `/api/settings/forge-connections/:id/test` | Check that the forge is reachable and accepts the token |
## API keys
@@ -164,10 +238,20 @@ endpoint at `/mcp`, not these REST routes.
## Plugin (Claude Code)
These serve the plugin's hooks.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/plugin/context` | SessionStart context payload (rules + active-project) |
| GET | `/api/plugin/retrieve` | Title-first knowledge-injection candidates |
| GET | `/api/plugin/context` | Session-start context: the active project and its state |
| GET | `/api/plugin/retrieve` | Records and rules matching the operator's prompt |
| GET | `/api/plugin/tool-rules` | Rules matching a tool call about to run |
| GET | `/api/plugin/moment-tools` | The tools whose calls can reach a moment |
| POST | `/api/plugin/moment` | The rules mounted on the moments a tool call reaches |
| POST | `/api/plugin/reply-rules` | Whether the reply ending a turn is held for one read |
| POST | `/api/plugin/rule-opened` | Records that a session opened a rule it was shown |
| GET | `/api/plugin/shape-check` | The end-of-turn question about what this turn wrote |
| GET | `/api/plugin/claim-session` · `/release-session` | Bind a session to its task claims / release them at session end |
| GET | `/api/plugin/write-scope` | Which projects a pending act would change (a directory, or an MCP call's `tool` and `input`), and whether a session in its own project may do it. Filing an issue elsewhere is allowed; every other cross-project write is answered `allowed: false` with the reason the operator is asked |
| GET | `/api/plugin/processes` | Stored Processes for skill-stub sync |
| GET | `/api/plugin/prior-art` | Write-path hint for the plugin hooks (params: `path`, `code`, `repo`, `shapes`, `exclude_ids`, `exclude_sync_ids`, `exclude_derive`); returns `context`, `note_ids`, `sync_note_ids`, `stamped`, `divergence`, `derive`, `derive_keys` |
| GET / POST | `/api/projects/<id>/coverage`, `…/coverage/refresh` | Shape-ledger accounting (`pattern_coverage` line, counts, `derive_groups` — css groups carry `consumers`, `derive_new`, `unused_css`, `divergence`, `recheck`) |
@@ -189,19 +273,32 @@ endpoint at `/mcp`, not these REST routes.
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/admin/backup` | Export backup (format v4; `?scope=user` for own data) |
| POST | `/api/admin/restore` | Restore from a backup |
| GET | `/api/admin/restore/sources` | The backups at the backup destination, newest first |
| POST | `/api/admin/restore/preview` | What restoring a backup (`?source=<name>` or an uploaded file) would change; 409 with the reason when it can't apply here |
| POST | `/api/admin/restore?confirm=rollback` | Put the whole instance back to that backup, after saving the current state |
| GET / DELETE | `/api/admin/users` · `/api/admin/users/:id` | List / delete users |
| GET / PUT | `/api/admin/registration` | Get / toggle registration |
| GET / PUT | `/api/admin/smtp` · POST `/api/admin/smtp/test` | SMTP config + test email |
| GET | `/api/admin/logs` · `/api/admin/logs/stats` | Log entries + category counts |
| GET / PUT | `/api/admin/base-url` | Get / set the public base URL |
| GET / PUT | `/api/admin/proxy` | Get / set how many reverse proxies stand in front of Grimoire (`{"hops": n}`, or `null` for the environment's count), with what each count would read for this request |
| GET / PUT | `/api/admin/db-maintenance` (+ `/health`, POST `/run`) | VACUUM schedule, health, manual run |
| POST / GET / DELETE | `/api/admin/invitations` (+ `/:id`) | Create / list / revoke invite links |
| GET / PUT | `/api/admin/backups` | Scheduled backups: destination, schedule and recent runs |
| POST | `/api/admin/backups/check` | Ask the destination whether it would accept a backup |
| POST | `/api/admin/backups/run` | Start a backup now |
| GET / PUT | `/api/admin/forge-webhook` | The forge push-webhook settings |
## Scribe MCP
## Webhooks
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/webhooks/forge` | Push events from a git forge, verified with `FORGE_WEBHOOK_SECRET` **(public)** |
## Grimoire MCP
Claude clients connect to the built-in MCP server at `POST /mcp` (streamable HTTP,
Bearer auth with an `fmcp_` key), served by `src/scribe/mcp/`. It is not a REST
Bearer auth with an `fmcp_` key), served by `src/grimoire/mcp/`. It is not a REST
surface — it exposes the same data as typed tools (`create_note`, `create_task`,
`start_planning`, `search`, `enter_project`, …) with
server-level usage guidance delivered in the MCP `instructions` block. See
+67 -352
View File
@@ -1,380 +1,95 @@
# Architecture
Grimoire is one app container and one PostgreSQL container. There is no language model in the stack: Claude works with Grimoire from outside, through the MCP endpoint and the Claude Code plugin, and people use the web UI.
## Stack
| Layer | Technology | Notes |
|-------|-----------|-------|
| Frontend | Vue 3 + TypeScript + Vite + Pinia + Vue Router | SPA served from the same container as the API |
| Editor | Tiptap (ProseMirror) with custom slash-command extension | |
| Backend | Python 3.12, Quart (async ASGI) | Serves both API and built frontend static files |
| Database | PostgreSQL 16, SQLAlchemy 2.0 async, Alembic | asyncpg driver |
| LLM | Ollama (local) | Any OpenAI-compatible API also works |
| Search | SearXNG (optional, self-hosted) | Web search + image search |
| Push | Web Push / VAPID (pywebpush 2.x) | |
| Deployment | Docker Compose | Single-container app + separate DB + LLM service |
| Layer | Technology |
|-------|-----------|
| Frontend | Vue 3, TypeScript, Vite, Pinia, Vue Router; Tiptap for the editor |
| Backend | Python 3.14, Quart (async), served by Hypercorn |
| MCP | The `mcp` SDK's server, mounted in the same app at `/mcp` (streamable HTTP) |
| Database | PostgreSQL 17 with pgvector; SQLAlchemy 2 (async, asyncpg); Alembic migrations |
| Embeddings | fastembed running in-process (`BAAI/bge-small-en-v1.5`, 384 dimensions) |
| Plugin | Claude Code hooks (POSIX shell + awk), skills and a slash command, shipped from this repo |
## High-Level Component Diagram
## How the pieces fit
```
┌─────────────────────────────────────────────┐
│ Docker Compose │
│ │
│ ┌──────────────────────┐ ┌────────────┐ │
│ │ scribe │ │ ollama │ │
│ │ ┌────────────────┐ │ │ │ │
│ │ │ Quart Server │ │ │ LLM API │ │
│ │ │ ┌──────────┐ │ │ │ │ │
│ │ │ │ Vue SPA │ │ │ └────────────┘ │
│ │ │ │ (static) │ │ │ ▲ │
│ │ │ └──────────┘ │ │ │ │
│ │ │ ┌──────────┐ │ │ HTTP/REST │
│ │ │ │ /api/* │──┼──┼─────────┘ │
│ │ │ └──────────┘ │ │ │
│ │ │ │ │ │ ┌────────────┐ │
│ │ │ ▼ │ │ │ PostgreSQL │ │
│ │ │ ┌──────────┐ │ │ │ 16 │ │
│ │ │ │ asyncpg │──┼──┼──▶ │ │
│ │ │ └──────────┘ │ │ └────────────┘ │
│ │ └────────────────┘ │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────┘
Claude Code ──── plugin hooks ──── /api/plugin/* ┐
│ │
└────────── MCP tools ──────── /mcp ├── Quart app ── PostgreSQL + pgvector
│ │
Browser ──────── Vue SPA ────────── /api/* ┘ └── in-process jobs and embedder
```
## Project Structure
- **The web UI** is a Vue single-page app, built into the image and served by Quart. Every unknown path that isn't under `/api/` falls back to `index.html`; `/api/*` always answers JSON.
- **The REST API** (`src/grimoire/routes/`) backs the UI, one blueprint per resource.
- **The MCP server** (`src/grimoire/mcp/`) is how Claude reads and writes records. Each module in `mcp/tools/` registers its tools, and the server's instructions and each tool's docstring are the contract an agent reads. Requests authenticate with an API key as a Bearer token.
- **The plugin** (`plugin/`) runs in the user's Claude Code. Its hooks call `/api/plugin/*` to put the right context in front of Claude at the right moment: the project at session start, matching rules and prior art before a prompt or a tool call, the rules mounted on a moment of work, and a check on the reply that ends a turn. Its skills describe the working practices. See [API Keys & MCP](api-keys-and-mcp.md) to install it.
- **Services** (`src/grimoire/services/`) hold the logic both doors share. Routes and MCP tools stay thin: parse the request, check access, call a service, return a plain dict.
```
scribe/
├── docker-compose.yml # Development stack
├── docker-compose.prod.yml # Production stack (Docker Swarm)
├── Dockerfile # Multi-stage build (Node → Python)
├── alembic/ # Database migrations
│ └── versions/ # Migration files (idempotent raw SQL)
├── src/scribe/
│ ├── app.py # Quart app factory + blueprint registration
│ ├── config.py # Config class (reads env vars)
│ ├── auth.py # login_required decorator, session checks
│ ├── models/ # SQLAlchemy models
│ ├── mcp/ # In-app MCP server (FastMCP, mounted at /mcp)
│ │ ├── server.py # FastMCP instance + instructions + Quart mount
│ │ └── tools/ # Tool modules (notes, tasks, projects, rulebooks, …)
│ ├── routes/ # API blueprints (one file per resource)
│ ├── services/ # Business logic (access, llm, tools, sharing, …)
│ └── static/ # Built Vue SPA (generated at Docker build time)
└── frontend/
└── src/
├── views/ # Page-level Vue components
├── components/ # Reusable UI components
├── composables/ # Vue composables (autosave, shortcuts, …)
├── stores/ # Pinia stores (auth, chat, notes, notifications, …)
└── api/ # Typed API client (client.ts)
```
## Data model
## Key Design Decisions
Most records are rows in one `notes` table, told apart by kind:
**Single container for frontend + API.** Quart serves the Vue.js production build as static files and exposes the REST API under `/api/`. The SPA is built by Vite during the Docker image build.
- **Notes**, and the specialised kinds that are notes underneath: **snippets** (recorded prior art, with a canonical code location), **lessons** (transferable insights, linked to the rules they instance) and **processes** (stored procedures, synced to Claude Code as skills).
- **Tasks**: a note with a status. A task's kind is `work`, `issue` or `spike`. Tasks carry work logs, sub-tasks, recurrence and a session claim.
**SPA routing via 404 handler.** `app.py` uses `@app.errorhandler(404)` (not a catch-all route) to serve static files or fall back to `index.html`. API routes (`/api/*`) always return JSON 404. This avoids a catch-all `/<path:path>` route intercepting API GETs.
Around them:
**Unified note/task model.** A task is just a note with task attributes enabled. `status IS NOT NULL` means it's a task. "Convert to task" sets `status='todo'`; "convert to note" clears `status`, `priority`, `due_date`. No separate table, no cascade complexity.
- **Projects** group work and carry a goal. **Milestones** are plans: a design body plus ordered step tasks.
- **Systems** are a project's named areas; records are tagged with them, and each maps to a canonical system shared across projects.
- **Rulebooks → topics → rules.** A rule in a rulebook is global; a rule on a project applies there only. Rules have a trigger (`when_to_apply`), versions, relations to other rules, and can be mounted on **moments** of work (`work.change`, `reply.report`, ...), which plugin actions reach through moment mappings.
- **Family canon**: ideas shared across projects on the same platform, and each project's answer to them (adopted, variant, owed).
- **Design systems**: tokens and guidance, with inheritance; the stylesheet is generated from them.
- **The shape ledger**: code shapes extracted from bound repos and their classification against snippets.
- **Embeddings** for notes (chunked), rules, milestones and systems, in pgvector columns.
- **Telemetry**: retrieval logs, note and rule usage, and the tuning history of retrieval thresholds.
- **Users, groups, shares and API keys.** Access is decided in the app (`services/access.py`): owners, direct shares and group shares, with read or write permission.
**First-class tag column.** Tags live in a `tags ARRAY[text]` column and are explicitly set by the client — not auto-extracted from body text. Hierarchical tags (`project/webapp`) supported via SQL `unnest + LIKE` prefix matching.
Deleted records go to the trash first and can be restored until retention purges them.
**Background generation architecture.** LLM streaming runs in a detached `asyncio.Task` that writes into an in-memory `GenerationBuffer`. SSE clients tail the buffer and can reconnect mid-stream without data loss. Buffer has a `cancel_event` for user-initiated stop. Completed buffers are cleaned up after 60s grace period. Periodic DB flushes every 5s preserve partial content. Both chat and AI Assist use this architecture.
## Retrieval
**SSE over WebSockets for LLM streaming.** SSE clients connect via `GET /api/chat/conversations/:id/generation/stream` with `Last-Event-ID` reconnection support. Frontend uses `fetch()` + `ReadableStream`.
Search and the plugin's context injection run on the same pipeline (`services/retrieval_pipeline.py`): embed the query, rank stored chunks by cosine similarity in pgvector, keep what clears the surface's threshold, and log what was shown. Each surface (MCP search, auto-inject, the pre-tool rule arm, the write-path prior-art arm, ...) is a spec over those stages with its own thresholds and budget. The thresholds are settings an admin can tune, and every change is recorded with its reason. When the embedder is unavailable, search falls back to keyword matching.
**Context building is server-side.** Backend fetches URL content and searches notes. Frontend sends the message text + optional context note IDs. `build_context()` returns `(messages, context_meta)`; metadata includes auto-found note IDs/titles sent to frontend via a `context` SSE event before streaming begins.
The embedding model downloads into `FASTEMBED_CACHE_DIR` (default `/data/fastembed-cache`) on first use, so the `/data` volume keeps it across restarts.
**No blocking long-running operations.** Any slow operation (model pulls, LLM calls, URL fetching) must never block app startup or freeze the UI. Backend uses SSE streaming for incremental responses. Model pulls stream NDJSON progress to the frontend.
## Background work
**SSRF protection.** `services/llm.py` blocks requests to loopback, private, link-local, reserved, and multicast addresses before fetching. `follow_redirects=False` prevents redirect-based bypasses.
All in-process; there is no separate worker container.
**Session cookie security.** `HttpOnly` and `SameSite=Lax` always set. `Secure` flag controlled by `SECURE_COOKIES` env var.
- At startup the app records any shipped default that moved in this release, starts its schedulers, and only then backfills missing embeddings, so the backfill never competes with startup for the database.
- Recurring tasks spawn every 15 minutes. Daily jobs: version auto-pinning (03:00 UTC), trash purge (03:30 UTC), targeted `VACUUM ANALYZE` (04:00 UTC by default). Scheduled backups check hourly whether one is due ([Backups](backups.md)).
- Log, notification and auth-token retention run on their own loops.
- A diagnostics heartbeat writes to `/data/diagnostics`.
**Rate limiting.** In-memory sliding-window rate limiter (`rate_limit.py`) applied to auth endpoints: login (10/60s), register (5/300s), forgot-password (5/300s), reset-password (10/60s). Keys are per-IP. `TRUST_PROXY_HEADERS` env var enables `X-Forwarded-For` / `X-Real-IP` when behind a reverse proxy.
**Idempotent migrations.** All Alembic migrations use raw SQL with `CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and `DO $$ BEGIN CREATE TYPE ... EXCEPTION WHEN duplicate_object` to allow safe re-runs.
## Data Model
### Users
| Column | Type | Notes |
|--------|------|-------|
| `id` | int PK | |
| `username` | text UNIQUE NOT NULL | |
| `email` | text nullable | |
| `password_hash` | text nullable | NULL for OAuth-only accounts |
| `oauth_sub` | text UNIQUE nullable | OIDC subject identifier |
| `role` | text NOT NULL DEFAULT 'user' | First user auto-assigned 'admin' |
| `session_version` | int NOT NULL DEFAULT 1 | Bumped on password change to evict sessions |
| `created_at` | timestamptz | |
### Notes (unified — includes tasks)
| Column | Type | Notes |
|--------|------|-------|
| `id` | int PK | |
| `title` | text | |
| `body` | text | Markdown |
| `tags` | ARRAY[text] | GIN indexed; explicitly set by client |
| `parent_id` | int FK self nullable | Sub-tasks / sub-notes |
| `user_id` | int FK users nullable | CASCADE |
| `project_id` | int FK projects nullable | |
| `milestone_id` | int FK milestones nullable | |
| `status` | text nullable | `todo`/`in_progress`/`done` — non-null = task |
| `priority` | text nullable | `none`/`low`/`medium`/`high` |
| `due_date` | date nullable | |
| `created_at`, `updated_at` | timestamptz | |
Indexes: GIN on `tags`, B-tree on `status`, B-tree on `title`.
### Settings
Composite PK `(user_id, key)`. Per-user key-value store. CRUD via `services/settings.py`. Used for: `default_model`, `assistant_name`, `journal_config` (JSON: locations, temp_unit, prep schedule), `user_timezone`, `voice_*`, etc.
### Conversations / Messages
`conversations`: `id`, `title`, `model`, `user_id`, `conversation_type` (`chat`/`journal`/`mcp`), `day_date` (YYYY-MM-DD for journal conversations), `rag_project_id` (nullable int — RAG scope: NULL=orphan-only, -1=all, positive=project), `created_at`, `updated_at`.
`messages`: `id`, `conversation_id` FK CASCADE, `role` (`user`/`assistant`), `content`, `status` (`done`/`generating`), `created_at`.
Title auto-generated by LLM on first exchange, re-generated every 10th message.
### Projects / Milestones
`projects`: `id`, `user_id`, `title`, `description`, `goal`, `status` (`active`/`completed`/`archived`), `color`, `auto_summary` (nullable text — LLM-generated summary for `search_projects` scoring), `summary_updated_at` (nullable timestamptz), timestamps.
`milestones`: `id`, `user_id`, `project_id` FK CASCADE, `title`, `description`, `status`, `order_index`, timestamps.
### Sharing & Access
`project_shares`, `note_shares`: each has `shared_with_user_id` OR `shared_with_group_id` (exclusive), `permission` (`viewer`/`editor`/`admin`), `invited_by`.
`groups`, `group_memberships`: platform-wide groups with `member`/`owner` roles.
Permission resolution is centralised in `services/access.py`. `get_project_permission(uid, project_id)` checks ownership → direct share → group-based share → note→project inheritance, returning the highest applicable permission.
### Journal-Related Tables
`weather_cache`: per-user, per-`location_key` cache. Columns: `user_id`, `location_key` (`home`/`work`/etc.), `location_label`, `forecast_json` (Open-Meteo response), `previous_json` (last forecast, used to detect changes), `fetched_at`. Lat/lon are *not* stored on the cache row — they live in the user's `journal_config.locations.{home|work}` setting and are used at refresh time.
### API Keys
`api_keys`: `id`, `user_id` FK CASCADE, `prefix` (first 8 chars, displayed in UI), `key_hash` (SHA-256 of full key — full key never stored), `name`, `scope` (`read`/`write`), `created_at`, `last_used_at`.
### App Logs
`category` (`audit`/`usage`/`error`), `user_id` FK nullable (SET NULL on delete), `username` (denormalised), `action`, `endpoint`, `method`, `status_code`, `duration_ms`, `error_type`, `error_message`, `traceback`, `details` JSONB.
## Detailed File Reference
### Backend (`src/scribe/`)
| File | Responsibility |
|------|---------------|
| `app.py` | Quart app factory; SPA via 404 handler; JSON 404/500 for API; request logging; security headers in `after_request` |
| `auth.py` | `login_required`, `admin_required`, `get_current_user_id` — shared `_check_auth()` helper; accepts session cookie or `Authorization: Bearer <key>` |
| `config.py` | All config from env vars + Docker secret file support (`_read_secret`); `SECURE_COOKIES`, `TRUST_PROXY_HEADERS`, `OLLAMA_NUM_CTX`; `oidc_enabled()`, `searxng_enabled()` classmethods |
| `rate_limit.py` | In-memory sliding-window rate limiter (`asyncio.Lock` + `defaultdict`); `is_rate_limited(key, max, window)` |
| `models/note.py` | Unified Note model (notes + tasks) |
| `models/user.py` | `id`, `username`, `email`, `password_hash` (nullable — NULL for OAuth-only), `oauth_sub` (unique nullable), `role`, `session_version` |
| `models/conversation.py` | `Conversation` + `Message` models |
| `models/app_log.py` | `AppLog` with `category`, denormalised `username`, `details` JSONB |
| `models/api_key.py` | `ApiKey`: `id`, `user_id`, `prefix`, `key_hash` (SHA-256), `name`, `scope` (`read`/`write`), `created_at`, `last_used_at` |
| `models/event.py` | Internal events store (`Event` model: `id`, `user_id`, `title`, `description`, `start_dt`, `end_dt`, `all_day`, `location`, `caldav_uid` nullable, `color`, timestamps) |
| `routes/api.py` | `/api` blueprint; `GET /api/health` (public) |
| `routes/auth.py` | Register, login, logout, me, password/email change, password reset, invite registration, OAuth login+callback; rate limiting; `LOCAL_AUTH_ENABLED` guards |
| `routes/admin.py` | Backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only) |
| `routes/chat.py` | Conversations CRUD; SSE generation stream; model pull/delete/list/warm |
| `routes/notes.py` | Notes CRUD + wikilinks + backlinks + assist + link suggestions + version history + drafts |
| `routes/tasks.py` | Tasks CRUD; `POST` accepts `project` name string (resolved to `project_id`) |
| `routes/task_logs.py` | Task work log CRUD (`GET/POST/DELETE /api/tasks/:id/logs`) |
| `routes/projects.py` | Projects CRUD + summary endpoint |
| `routes/milestones.py` | Milestones CRUD under `/api/projects/:id/milestones` |
| `routes/settings.py` | Per-user settings key-value (`GET/PUT /api/settings/:key`) |
| `routes/journal.py` | Journal config CRUD; today/day/days conversation accessors; weather endpoints (cached/current/refresh/geocode); moments CRUD; trigger-prep |
| `routes/groups.py` | Group CRUD + membership management (admin) |
| `routes/shares.py` | Share project/note with user or group; revoke; list incoming shares |
| `routes/in_app_notifications.py` | In-app notification list + mark-read + count |
| `routes/push.py` | Web Push subscription subscribe/unsubscribe; VAPID public key |
| `routes/users.py` | User profile; admin user list + delete |
| `routes/images.py` | Serve cached images at `/api/images/<id>` |
| `routes/export.py` | `GET /api/export` — personal Markdown ZIP or JSON array download |
| `routes/api_keys.py` | API key CRUD (`GET/POST/DELETE /api/api-keys`) |
| `mcp/server.py` | Mounts the in-app FastMCP server at `/mcp` (streamable HTTP, Bearer auth) |
| `routes/quick_capture.py` | `POST /api/quick-capture` — single-shot natural language item creation |
| `routes/search.py` | `GET /api/search` — semantic + keyword hybrid search |
| `services/auth.py` | `create_user`, `authenticate`, user lookups, password reset tokens, invitation tokens |
| `services/oauth.py` | OIDC discovery (cached), PKCE auth URL, code exchange, `find_or_create_oauth_user` |
| `services/api_keys.py` | `generate_key()`, `create_api_key()`, `list_api_keys()`, `revoke_api_key()`, `lookup_key()` (SHA-256 hash lookup) |
| `services/llm.py` | `build_context()`, RAG injection, history summarisation, `stream_chat_with_tools()`, URL fetching, SSRF guard |
| `services/generation_task.py` | `run_generation()` — full chat pipeline: intent routing, tool loop, SSE fan-out, push notification; `run_assist_generation()` |
| ~~`services/intent.py`~~ | Removed — intent routing eliminated; the main model handles all tool routing directly |
| `services/tools/` | LLM tool package — decorator-based registry (`_registry.py`), shared helpers (`_helpers.py`), and one module per domain: `notes.py` (create/update/delete/search/list/read), `tasks.py` (list/log_work), `entities.py` (save_person/save_place/lists), `projects.py`, `calendar.py`, `web.py`, `rag.py`, `profile.py`, `rss.py`, `weather.py`, `utility.py` (calculate). `execute_tool()` and `get_tools_for_user()` are the public API. |
| `services/projects.py` | Project CRUD + `generate_project_summary()` (Ollama, fire-and-forget) + `backfill_project_summaries()` (startup) |
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes(orphan_only=False)` (pgvector cosine similarity) |
| `services/generation_buffer.py` | In-memory SSE event buffer; `cancel_event`; 60s cleanup; supports both chat (int keys) and assist (string keys) |
| `services/notes.py` | Note CRUD, wikilink resolution, backlink queries, tag management |
| `services/note_versions.py` | Version snapshot on save; restore-from-version; diff metadata |
| `services/note_drafts.py` | Per-user per-note draft persistence (AI Assist pending state) |
| `services/settings.py` | `get_setting()`, `set_setting()`, `get_all_settings()` — key-value per user |
| `services/tag_suggestions.py` | `/api/notes/suggest-tags` — LLM-generated tag suggestions for note body |
| `services/access.py` | Permission resolution for all shared resources |
| `services/sharing.py` | Create/revoke shares; list shares; `get_shared_with_me()` |
| `services/groups.py` | Group CRUD; membership management |
| `services/notifications.py` | Create/read/mark-read in-app notifications |
| `services/task_logs.py` | Append/list/delete work log entries on tasks |
| `services/journal_prep.py` | Deterministic data gather (tasks/events/weather/projects/recent moments/open threads) → LLM prose opener; persisted as the first assistant message of today's journal Conversation |
| `services/journal_pipeline.py` | System-prompt builder for journal conversations; calls `build_profile_context()` so the LLM sees the user's profile + learned summary |
| `services/journal_scheduler.py` | APScheduler `BackgroundScheduler`; per-user prep job; live-reschedule via `update_user_schedule()`; catch-up logic for missed runs |
| `services/journal_search.py`, `services/moments.py` | Moment recording + search across journal history |
| `services/user_profile.py` | `build_profile_context()` consolidates profile + observations + learned_summary into a system-prompt block |
| `services/research.py` | SearXNG research pipeline: sub-queries → parallel fetch → outline → section synthesis → executive summary → index note with linked section notes |
| `services/events.py` | Internal events CRUD: `list_events`, `create_event`, `update_event`, `delete_event`, `get_event`; source of truth for all event LLM tools |
| `routes/events.py` | `/api/events` — event CRUD routes |
| `services/caldav.py` | Optional CalDAV sync — user-configured external server; syncs to/from internal store via `caldav_uid` FK; `is_caldav_configured()` guards tool activation |
| `services/calendar_sync.py` | Dead code — Radicale sync service; was trialled and removed |
| `services/images.py` | `fetch_and_store_image()` — SHA-256 dedup, content-type validation, 5 MB cap |
| `services/backup.py` | `export_full_backup()`, `export_user_backup()`, `restore_full_backup()` (version 2 with ID maps) |
| `services/push.py` | VAPID key auto-generation; `send_push_notification()` fire-and-forget; 410 Gone cleanup |
| `services/logging.py` | `log_audit`, `log_usage`, `log_error`, `start_log_retention_loop` (hourly cleanup) |
| `services/email.py` | SMTP email sending for password reset; reads `SMTP_*` env vars |
| `services/weather.py` | Nominatim geocoding + Open-Meteo forecast + per-user DB cache |
| `services/rss.py` | feedparser fetch; per-feed DB cache; prune-to-100 items |
| `services/assist.py` | `build_assist_messages()` — section/whole-doc modes; project context injection |
### Frontend (`frontend/src/`)
| File | Responsibility |
|------|---------------|
| `App.vue` | App shell (`100dvh` flex column); global keyboard shortcuts (`g`+key nav, `n/t/c/e///?`); starts status polling + loads settings on mount |
| `api/client.ts` | `ApiError`, `apiGet/Post/Put/Patch/Delete`, `apiSSEStream` (fetch + ReadableStream), auto 401→login redirect |
| `stores/auth.ts` | User, `isAuthenticated`, `isAdmin`, `oauthEnabled`, `localAuthEnabled`, login/register/logout/checkAuth |
| `stores/chat.ts` | Conversation CRUD; `sendMessage()` SSE streaming; `reconnectIfGenerating()`; message queue (localStorage); `streamingStatus` |
| `stores/notes.ts` | CRUD + tag filter; `resolveTitle`; `convertToTask/Note`; `fetchBacklinks`; `fetchAllTags` |
| `stores/tasks.ts` | CRUD + status/priority filter; `patchStatus` |
| `stores/settings.ts` | `assistantName`, `defaultModel`, `installedModels`; `pullModel()`, `deleteModel()` |
| `stores/push.ts` | `isSupported`, `permission`, `isSubscribed`, `subscribe/unsubscribe` |
| `stores/notifications.ts` | `count`, `items`, `fetchCount`, `fetchAll`, `markRead`, `markAll` |
| `views/HomeView.vue` | Chat-first dashboard; 6 task sections (Overdue → Other); 8 recent notes; inline streaming response |
| `views/ChatView.vue` | `/chat` — SSE streaming; context sidebar (Suggested/In Context); note picker; scope chip (RAG project scope pill above input); message queue; bulk delete |
| `views/CalendarView.vue` | `/calendar` — FullCalendar v6 month/week/day views; click to create event; click event to open EventSlideOver |
| `components/EventSlideOver.vue` | Reusable slide-over for event create/edit/delete; used in ToolCallCard, HomeView, CalendarView |
| `views/WorkspaceView.vue` | `/workspace/:id` — 3-panel (tasks/chat/notes); SSE tool-call watcher; conv persisted to localStorage |
| `views/GraphView.vue` | `/graph` — D3 force-directed; tag/note/project-hub nodes; physics panel; peek panel |
| `views/ProjectView.vue` | Kanban grouped by milestone; advance buttons; milestone management |
| `views/SettingsView.vue` | Tabbed settings (11 tabs); tab state in localStorage |
| `views/NoteEditorView.vue` | Tiptap editor; 2-column layout; AI assist panel; diff view; version history; autosave |
| `views/TaskEditorView.vue` | Tiptap editor; 2-column layout; AI assist panel; task log section; sub-tasks |
| `components/ToolCallCard.vue` | Tool call results; `requires_confirmation` inline confirm/deny; direct POST on "Create anyway" |
| `components/ChatMessage.vue` | Message bubble; markdown rendering; tool call cards; "Save as Note" |
| `components/TagInput.vue` | Chip-based tag input; Enter/comma to confirm; autocomplete from `/api/notes/tags` |
| `components/TiptapEditor.vue` | Tiptap wrapper; markdown↔HTML round-trip; selection change emit; WikilinkDecoration; TagDecoration |
| `components/ShareDialog.vue` | `<Teleport>` modal; user tab + group tab; current shares list |
| `components/WorkspaceTaskPanel.vue` | Milestone-grouped task list; detail slide-over |
| `components/WorkspaceNoteEditor.vue` | List ↔ TipTap editor with autosave |
| `composables/useAssist.ts` | AI assist: section parsing, SSE streaming, accept/reject, LCS diff, persistent draft |
| `composables/useAutoSave.ts` | Interval-based autosave (5 min) with dirty/saving guards |
| `composables/useEditorGuards.ts` | Ctrl+S, `beforeunload` warning, route-leave confirm |
| `composables/useTagSuggestions.ts` | `/api/notes/suggest-tags` call + suggestion state |
| `composables/useListKeyboardNavigation.ts` | j/k/Enter list navigation; focus-in-input guard |
| `extensions/WikilinkDecoration.ts` | ProseMirror decoration plugin highlighting `[[wikilinks]]` |
| `extensions/TagDecoration.ts` | ProseMirror decoration plugin highlighting `#tags` |
| `extensions/WikilinkSuggestion.ts` | `@tiptap/suggestion` extension for `[[` autocomplete |
## Key Services
| Service | Responsibility |
|---------|---------------|
| `services/access.py` | Permission resolution for all shared resources |
| `services/llm.py` | `build_context()`, RAG injection, history summarisation |
| `services/generation_task.py` | SSE streaming, tool-call loop, GenerationBuffer management |
| `services/tools/` | LLM tool implementations (38 tools across 11 modules); decorator-based registry |
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes()` |
| `services/journal_prep.py` | Data gather → LLM prose opener; persisted into today's journal conversation |
| `services/journal_scheduler.py` | APScheduler integration, per-user prep job, catch-up for missed runs |
| `services/backup.py` | Full and per-user backup export/restore (version 2 format) |
| `services/weather.py` | Nominatim geocoding + Open-Meteo forecast fetch + DB cache |
| `services/rss.py` | feedparser-based fetch, per-feed DB cache, prune-to-100 |
Every database wait on startup has a deadline. Migrations wait for the database to accept connections (up to five minutes) before they run, so the app starts cleanly whatever order the containers come up in.
## Authentication
**Session cookies** — `HttpOnly`, `SameSite=Lax`, optionally `Secure` (`SECURE_COOKIES` env var). Session includes `session_version`; mismatch with DB value (after password change) results in 401 and session clear.
- **Sessions**: `HttpOnly`, `SameSite=Lax` cookies, `Secure` on a request the trusted proxy reports as HTTPS, which also gets HSTS (`grimoire/proxy.py`). Changing a password bumps the user's session version and signs out their other sessions.
- **API keys**: `Authorization: Bearer fmcp_...`, stored hashed. A `read` key may only call read endpoints and read MCP tools.
- **OIDC** single sign-on with PKCE, optionally replacing local logins ([SSO / OAuth](sso-oauth.md)).
- **Rate limits** on register, login and forgot-password, per client IP, read from `X-Forwarded-For` by `TRUSTED_PROXY_HOPS`.
**Bearer token / API keys** — `Authorization: Bearer <key>` accepted by `_check_auth()` as an alternative to session cookies. The raw key is SHA-256 hashed and looked up via `services/api_keys.py`. Keys with `scope=read` are rejected on non-safe methods (`POST`/`PATCH`/`DELETE`). Used by Fable MCP and any external API consumers.
Credentials Grimoire stores for outside services (forge tokens, the backup destination's S3 secret) are sealed under a key derived from `SECRET_KEY`, which the instance makes on first start and keeps in `/data/secret_key` unless one is set.
**Local auth + OIDC/OAuth (PKCE)** — `services/oauth.py` handles discovery and `find_or_create_oauth_user`. On OAuth login: checks existing `oauth_sub` → matching email → creates new user.
## Repository layout
See [sso-oauth.md](sso-oauth.md) for provider-specific setup instructions.
## LLM Pipeline Internals
### Tool Routing
No separate intent router — the main model handles all tool routing directly via Ollama's structured tool-calling output. The model receives the full tool schema list and decides whether to call a tool or respond conversationally. Extended reasoning (`think=True`) is always on for qwen3-class models: content-based gating was tried but exposed tool-call template fragility on short tool-intent prompts.
### Tool Loop
Multi-round tool loop (max 5 rounds). All implementations in `services/tools/` (decorator-based registry); `execute_tool(user_id, tool_name, arguments, conv_id=None, workspace_project_id=None)` is the dispatcher. `conv_id` and `workspace_project_id` are threaded in from `run_generation()` so tools like `set_rag_scope` can write to the current conversation.
**Unified `create_note` tool** — creates both notes and tasks. Setting `status` (e.g. `"todo"`) creates a task; omitting it creates a knowledge note. All task fields (due_date, priority, milestone, parent_task, recurrence_rule) are available on the single tool.
**Duplicate protection on `create_note`:**
1. Exact title match (case-insensitive) → hard block, redirect to `update_note`
2. Fuzzy title match (SequenceMatcher ≥ 82%; punctuation stripped before candidate search) → hard block
3. Semantic content similarity (threshold 0.90, body ≥ 80 chars) → soft block with `requires_confirmation: true`
**Project resolution** (`_helpers.resolve_project`): 4-step lookup — (1) exact DB match, (2) `query in title` substring, (3) `title in query` reverse substring, (4) SequenceMatcher ≥ 0.55.
### Context Window and Summarisation
`OLLAMA_NUM_CTX` (default 16384) controls the context window for all generation calls. Intent classification always uses `num_ctx=4096` to reduce VRAM pressure.
History summarisation threshold: 30 messages. Keeps 8 recent messages. Summary max 400 tokens.
### Web Research Pipeline
`services/research.py` implements a full autonomous research pipeline:
1. Intent model generates 5 focused sub-queries
2. All 5 SearXNG queries run in parallel (200ms stagger to avoid rate limiter)
3. Up to 15 unique URLs fetched in parallel
4. LLM generates an outline (2–8 sections with title + focus)
5. Each section synthesised in parallel from relevant sources
6. Executive summary generated from all section content
7. Index note created with executive summary + links to section notes; section notes linked back via `parent_id`
8. Falls back to single-note synthesis if outline generation fails
SearXNG tip: add the app server IP to `botdetection.ip_lists.pass_ip` in SearXNG `settings.yml` to bypass the rate limiter for trusted backend requests.
### Image Cache
`search_images` tool fetches images server-side via SearXNG, stores them on disk (SHA-256 dedup, content-type validation, 5 MB cap), and serves from `/api/images/<id>`. The user's browser never contacts the original image host.
Config: `IMAGE_CACHE_DIR` (default `/data/images`), `IMAGE_MAX_BYTES` (default 5 MB).
## RAG Pipeline
1. `semantic_search_notes()` — cosine similarity via pgvector, threshold configurable per call; accepts `orphan_only` and `project_id` scope flags.
2. Notes ≥ 0.60 similarity auto-injected into system prompt (up to 3, 800 chars each).
3. Notes 0.45–0.60 surfaced in chat sidebar as "Suggested" (user clicks to include).
4. Explicitly included notes delivered full-body.
5. `excluded_note_ids` prevents the current note from being injected as its own context.
### RAG Scope (three-value system)
`conversations.rag_project_id` controls which notes are eligible for retrieval:
| Value | Behaviour |
|-------|-----------|
| `NULL` (default) | Orphan notes only — notes with `project_id IS NULL` |
| `-1` | All notes — opt-in to global search |
| Positive int | That project's notes only |
`build_context()` derives `orphan_only` + `effective_project_id` from this value before calling both the semantic and keyword search paths.
**Scope tools** — Two LLM tools let the model discover and switch scope mid-conversation:
- `search_projects` — SequenceMatcher scoring over title + description + `auto_summary`; returns top 5 matching projects.
- `set_rag_scope` — persists the new `rag_project_id` to DB immediately; blocked in workspace view; causes the SSE `done` event to include `new_rag_scope` + `new_rag_scope_label` so the frontend chip updates reactively.
**Project summaries** — `generate_project_summary()` calls Ollama (fire-and-forget) and stores the result in `projects.auto_summary`. Triggered on project update and note saves (debounced 1h). `backfill_project_summaries()` runs at startup.
Embedding model: `nomic-embed-text` via Ollama. Backfill runs 30s after startup (background task).
```
├── alembic/versions/ database migrations
├── src/grimoire/
│ ├── app.py app factory, blueprints, startup and shutdown
│ ├── config.py environment configuration
│ ├── models/ SQLAlchemy models
│ ├── routes/ REST blueprints
│ ├── mcp/ MCP server and its tool modules
│ ├── services/ shared logic
│ └── restore.py `python -m grimoire.restore`: the restore, from the command line
├── frontend/src/ views, components, stores, composables, api client
├── plugin/ Claude Code hooks, skills and commands
├── scripts/ CI checks and the plugin version minter
└── tests/ unit tests, plus `-m integration` against real Postgres
```
+195
View File
@@ -0,0 +1,195 @@
# Backups
Grimoire can back itself up every day to a **folder** or an **S3-compatible
bucket**. Each run adds one file; Grimoire never deletes an old one. Keeping and
thinning the history is the destination's job.
Set it up under **Settings → Data → Scheduled backups** (admins only).
## What a backup is
One file per run, named for the moment it was taken in UTC:
```
grimoire-20261015T020000Z.tar
```
Backups written while the app was called Scribe are named `scribe-…tar`.
They are counted with the rest and restore the same way.
It is a plain, **uncompressed** tar:
- `manifest.json` comes first. It holds the backup format version, the Grimoire
version, when the backup was taken, and how many rows each table has.
- Then there is one `<table>.jsonl` per table, one row per line.
It holds everything the admin **Full Backup** download holds, laid out
differently. It does **not** hold:
- **Search indexes.** They are rebuilt from the records when Grimoire starts.
- **Sharing:** groups, project shares and note shares.
- **Credentials:** API keys and forge connections. After a restore, issue new
API keys and add forge connections again. (Forge tokens are also sealed under
`SECRET_KEY` in the database itself, so a copy of the database doesn't carry
them in readable form either. The key lives in `/data/secret_key`, outside
the database.)
- **Logs and notifications.**
- **The backup destination's own S3 secret.**
The file contains hashed passwords, so store it somewhere only trusted people
can read.
### Why it is shaped this way
The file is meant to be copied off-site by a deduplicating tool such as
restic, which stores a chunk of data only once no matter how many backups
contain it. Every choice serves that:
- **It is never compressed.** Compression changes every byte after the first
edit, so nothing would match the day before. restic compresses its own
repository after deduplicating, so nothing is lost.
- **Rows are in primary-key order, with their keys sorted.** A row that did
not change is the same bytes in the same place every day. An edited row
changes one line, and new rows land at the end of their table's file.
- **Each table is its own file**, so a busy table never shifts the bytes of a
quiet one.
- **The parts that change every run are kept in `manifest.json`**: the time
and the counts.
- **Each run gets a new, dated name.** Overwriting one name would keep one
copy and no history.
Day to day, the off-site copy then stores roughly what changed, not the whole
file again.
## Destinations
### A folder
A path **inside the Grimoire container**, normally a share mounted there:
```yaml
services:
grimoire:
volumes:
- /nfs/backup/grimoire:/backups
```
Grimoire writes each file under a hidden name (`.grimoire-….tar.part`) and renames
it once it is complete. A program watching the folder never sees half a
backup.
### An S3-compatible bucket
You need four things:
- **Address:** the service itself, like `https://backup.example.com`. Don't
include the bucket; Grimoire addresses buckets by path.
- **Bucket.**
- **Access key.**
- **Secret key.**
Grimoire uploads and lists, and reads a backup back when you restore from the
bucket; it never deletes. A key that may **add and read objects but not
delete them** is therefore enough, and it is the safer key to hand out: a
stolen one cannot wipe the history. A key that may only add objects still
takes backups; restore then works by uploading a file or from the command
line.
The secret is stored encrypted, under a key derived from `SECRET_KEY`. It is
never shown again, and never included in a backup. If an instance's
`SECRET_KEY` changes, for example after a restore onto a new instance, the
page asks for the secret again.
**Test destination** checks the destination without sending a backup:
- a folder: it writes and removes a hidden file;
- a bucket: it only lists, because an add-only key could never remove a test
file.
## Schedule and history
- Backups run once a day at the hour you choose, in UTC. The default is 02:00,
before the nightly trash purge and database maintenance.
- **Back up now** runs one straight away.
- The page leads with when the last good backup was taken. It says
**Overdue** when backups are on and none has worked for 26 hours.
- It lists the last 30 runs, and gives the reason for each failure in words.
- A run that hasn't finished after an hour is abandoned and recorded as
failed.
## Example: Fabled Archives
[Fabled Archives](https://git.fabledsword.com/bvandeusen/fabledarchives)
keeps service backups as "backup targets". Either destination works with it.
1. On Archives' **Backups** page, make a target for Grimoire, named `grimoire`
for example.
2. Choose how Grimoire's files arrive:
- **S3**: turn on the S3 way in for the target and make an S3 key that
may add and read objects but not delete them (an **append-only** key
takes backups too, but then Grimoire can't restore from the bucket). In
Grimoire, choose **An S3 bucket**:
- address: Archives' backup address (`http://<archives>:8081`);
- bucket: the target's name;
- access key and secret key: the key you just made.
- **A folder**: mount the share Archives watches into Grimoire as
`/backups/grimoire`, and point the target at that folder. In Grimoire, choose
**A folder** with `/backups/grimoire`.
3. Set the target's **Retention** to **Archives**. Archives then keeps the
newest backups and thins the rest by day, week and month. Grimoire only adds.
4. Give the target an expected cadence of **daily**, so a missed backup turns
it late in Archives as well as overdue in Grimoire.
5. Add an off-site **restic** replication job for the target. The uncompressed
layout above is what lets that job send only what changed each day.
## Restoring
A restore puts the **whole instance** back to the moment a backup was taken,
for every user:
- records edited since go back to how they were;
- records deleted since come back;
- anything made since is removed.
It matches records by their ids, so a live instance only restores a backup **of
its own**. Each instance has an id, and every backup records the one it came
from. A backup from another instance, or one made before backups recorded it,
only goes into a new, empty instance, which takes it as it is.
Before changing anything, the restore saves the current state as one more
backup. It goes to the backup destination when scheduled backups are on, and
otherwise to `/data/pre-restore`. The result says which file it is, so a
restore can itself be undone.
What a backup doesn't carry stays as it is wherever its owner survives the
restore: API keys, forge connections, sharing, logs, and the destination's S3
secret. The trash isn't in backups, so anything in the trash when the backup
was taken is gone afterwards. Everyone is signed out, and search is rebuilt.
### From the web
**Settings → Data → Restore from Backup** lists the backups at the backup
destination. You can pick one of those, or upload a `.tar` or the JSON
**Full Backup** download (uploads are held to the server's request-size
limit). The restore then shows what would change: when the backup was taken,
and how many accounts, projects, plans, notes and rules there are now and will
be afterwards. Type `roll back` to run it. Then sign in again.
To read backups back from an S3 bucket, the key needs read access as well as
write. Grimoire never deletes from the bucket.
### From the command line
For a file with no size limit, or a new instance with no backup destination
yet, copy the file into the container and run:
```sh
docker exec -it grimoire python -m grimoire.restore /backups/grimoire-20261015T020000Z.tar
```
It prints what would change and stops. Add `--yes` to restore. It reads the
JSON download too. Afterwards, **restart Grimoire** so the search indexes are
rebuilt.
After restoring into a new instance, issue new API keys, add forge connections
again, and set up any sharing: none of them travel in a backup.
+78 -98
View File
@@ -1,149 +1,129 @@
# Configuration
Configuration is via environment variables. The `docker-compose.yml` file sets defaults for local development; override them in a `.env` file (gitignored).
Grimoire reads its configuration from environment variables. Most settings people change day to day (registration, SMTP, retrieval thresholds, backups, forge connections) live in **Settings** in the web UI instead.
## Environment Variables
Any variable marked *secret* can also be read from a file: set `<NAME>_FILE` to the path, for example a Docker secret at `/run/secrets/grimoire_key`.
## Environment variables
### Core
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/scribe` | PostgreSQL async connection string |
| `SECRET_KEY` | `dev-secret-change-me` | Session signing key — **change this in production** |
| `SECRET_KEY_FILE` | — | Path to a Docker secret file containing the key (alternative to `SECRET_KEY`) |
| `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
| `SECURE_COOKIES` | `false` | Set `true` when running behind TLS |
| `BASE_URL` | — | Public URL (e.g. `https://notes.example.com`) — required for OIDC redirect URIs and email links |
| `DATABASE_URL` *(secret)* | `postgresql+asyncpg://grimoire:grimoire@localhost:5432/grimoire` | PostgreSQL connection string, using the `asyncpg` driver |
| `SECRET_KEY` *(secret)* | kept in `DATA_DIR` | Signs sessions and seals stored credentials. Unset, the instance makes its own on first start and keeps it in `DATA_DIR/secret_key`. Set it only to manage the key yourself; it wins over the kept one (see below) |
| `DATA_DIR` | `/data` | Where the instance keeps its own `SECRET_KEY`. Mount a volume here |
| `BASE_URL` | `http://localhost:5000` | Public URL, used in email links and OIDC redirects |
| `TRUSTED_PROXY_HOPS` | `1` | The starting count of reverse proxies in front of Grimoire. **Settings → Config → Reverse proxy** is where the count is checked and set, and a count saved there wins over this. The client address and the scheme are read that many entries in from the right of `X-Forwarded-For` and `X-Forwarded-Proto`; `0` ignores both headers. See [Behind a reverse proxy](#behind-a-reverse-proxy) |
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING` or `ERROR` |
| `LOG_RETENTION_DAYS` | `90` | Days of app logs to keep |
| `FASTEMBED_CACHE_DIR` | `/data/fastembed-cache` | Where the embedding model is stored after its first download |
### LLM / Ollama
### Sign-in
| Variable | Default | Description |
|----------|---------|-------------|
| `OLLAMA_URL` | `http://ollama:11434` | Ollama API base URL |
| `OLLAMA_MODEL` | `llama3.2` | Default LLM model (used as fallback; per-user setting overrides this) |
| `EMBEDDING_MODEL` | `nomic-embed-text` | Model used for semantic search / RAG embeddings |
| `OLLAMA_NUM_CTX` | `16384` | Context window size passed to Ollama for all generation calls |
### Authentication / OIDC
| Variable | Default | Description |
|----------|---------|-------------|
| `LOCAL_AUTH_ENABLED` | `true` | Set `false` to disable local username/password login (SSO-only mode) |
| `OIDC_ISSUER` | — | OIDC issuer URL (e.g. `https://auth.example.com/application/o/fabled/`) |
| `LOCAL_AUTH_ENABLED` | `true` | `false` turns off username/password login, leaving SSO only |
| `OIDC_ISSUER` | — | OIDC issuer URL |
| `OIDC_CLIENT_ID` | — | OIDC client ID |
| `OIDC_CLIENT_SECRET` | — | OIDC client secret |
| `OIDC_CLIENT_SECRET_FILE` | — | Docker secret file alternative to `OIDC_CLIENT_SECRET` |
| `OIDC_SCOPES` | `openid profile email` | Space-separated OIDC scopes to request |
| `OIDC_CLIENT_SECRET` *(secret)* | — | OIDC client secret |
| `OIDC_SCOPES` | `openid profile email` | Scopes to request |
See [sso-oauth.md](sso-oauth.md) for provider-specific setup.
SSO turns on when the issuer, client ID and secret are all set. See [SSO / OAuth](sso-oauth.md).
### Security / Proxy
### Email
Admins can set SMTP in **Settings**; these are the defaults until they do.
| Variable | Default |
|----------|---------|
| `SMTP_HOST` | — |
| `SMTP_PORT` | `587` |
| `SMTP_USERNAME` | — |
| `SMTP_PASSWORD` *(secret)* | — |
| `SMTP_FROM_ADDRESS` | — |
| `SMTP_FROM_NAME` | `Fabled Grimoire` |
| `SMTP_USE_TLS` | `true` |
### Integrations
| Variable | Default | Description |
|----------|---------|-------------|
| `TRUST_PROXY_HEADERS` | `false` | Set `true` when behind a trusted reverse proxy to read real IP from `X-Forwarded-For` / `X-Real-IP` |
| `PLUGIN_MARKETPLACE_URL` | this repo's git URL | The git URL shown in the plugin install command under Settings → MCP Access. An admin can override it in Settings |
| `FORGE_KIND`, `FORGE_BASE_URL`, `FORGE_TOKEN` *(secret)* | — | A git forge connection for admins' projects, so a token can live in a Docker secret. Each user can add their own under Settings → Integrations; a stored connection for the same host wins |
| `FORGE_WEBHOOK_SECRET` *(secret)* | — | Verifies push webhooks from the forge |
| `SEARXNG_URL` | — | A SearXNG instance, listed as the web-search backend under Settings → Integrations |
### Web Search / Images
## Running it
| Variable | Default | Description |
|----------|---------|-------------|
| `SEARXNG_URL` | — | SearXNG base URL for web search and image search tools |
| `IMAGE_CACHE_DIR` | `/data/images` | Directory for cached search images |
| `IMAGE_MAX_BYTES` | `5242880` (5 MB) | Maximum size for cached images |
### Docker Compose
### Data / Logging
| Variable | Default | Description |
|----------|---------|-------------|
| `LOG_RETENTION_DAYS` | `90` | Days to keep app logs before automatic pruning |
| `DATA_DIR` | `/data` | Root directory for persistent data (VAPID keys, backups) |
## Docker Compose Setup
### Development (`docker-compose.yml`)
[`docker-compose.yml`](../docker-compose.yml) runs the published image with its own database, and needs nothing set:
```bash
# Copy the example env file
cp .env.example .env
# Edit .env to set a real SECRET_KEY
# Start the stack
docker compose up --build
# App available at http://localhost:5000
docker compose up -d
```
The first user to register becomes admin. Registration auto-closes after that (re-enable from Settings → Users).
Open `http://localhost:5000`. The first account registered becomes the admin; after that, registration stays closed until an admin opens it in Settings or sends an invite.
### Production (`docker-compose.prod.yml`)
Update with `docker compose pull && docker compose up -d`. The key and the data stay in their volumes.
The production compose file adds:
- Docker Secrets for `SECRET_KEY_FILE` and `DATABASE_URL_FILE`
- Network isolation (internal bridge for DB + Ollama; only the app is externally accessible)
- Health checks and resource limits
### From source
```bash
# Create Docker secrets
echo "$(python3 -c 'import secrets; print(secrets.token_hex(32))')" | docker secret create fabled_secret_key -
echo "postgresql+asyncpg://fabled:strongpassword@db/scribe" | docker secret create fabled_db_url -
# Deploy
docker stack deploy -c docker-compose.prod.yml fabled
docker compose -f docker-compose.dev.yml up --build
```
## Production Deployment
### Docker Swarm
### Reverse Proxy (Required)
[`docker-compose.prod.yml`](../docker-compose.prod.yml) is a stack file: the app and database on a private overlay network, trusting one proxy (`TRUSTED_PROXY_HOPS: "1"`). A Swarm can move the app to another node, away from a node-local `/data` volume, so the stack sets its own key: provide `DB_PASSWORD` and `SECRET_KEY` in the environment (or mount a Docker secret and set `SECRET_KEY_FILE`):
Fabled Assistant does **not** handle SSL/TLS. Run it behind a reverse proxy:
```bash
DB_PASSWORD=... SECRET_KEY=... docker stack deploy -c docker-compose.prod.yml grimoire
```
- **Nginx**, **Traefik**, or **Caddy** in front of the app container
- Terminate TLS at the proxy; forward to port 5000
- **Do not expose port 5000 directly to the internet**
- Rate-limit auth endpoints: ≤ 5 req/min per IP on `/api/auth/login` and `/api/auth/register`
The database user, database and volume default to `grimoire`. An install created under the name Scribe keeps its data by setting `POSTGRES_USER`, `POSTGRES_DB` and `PGDATA_VOLUME` to the names it was created with.
The shipped files leave out healthchecks and restart policies on purpose. The app waits for its database on startup, and the orchestrator's defaults restart it if it ever exits.
### Volumes
- **Database** (`pgdata`): everything Grimoire stores.
- **App data** (`/data`): the instance's own `SECRET_KEY`, the embedding model cache and diagnostics. Losing it costs the key: Settings then asks for saved forge tokens and the backup secret again, and the model downloads again.
Back up the database with **Settings → Data → Scheduled backups** ([Backups](backups.md)). A database backup doesn't carry the key; to move sealed credentials to a new instance, copy `/data/secret_key` with it.
## Behind a reverse proxy
Grimoire doesn't terminate TLS. Put it behind Nginx, Traefik or Caddy, forward to port 5000, and keep port 5000 off the internet. Set `BASE_URL`, then set the proxy count to the number of proxies a request passes through: one proxy is `1`, the default; Cloudflare's proxied DNS in front of your own proxy is `2`.
**Settings → Config → Reverse proxy** sets the count and checks it against your own request. It shows the address Grimoire reads you as, and what each count from 0 to 3 would read. Pick the count that shows your own address. When a request comes through Cloudflare, the section names the count that reads the address Cloudflare reports, with a button that sets it. `TRUSTED_PROXY_HOPS` in the environment is only the starting value: a count saved in Settings wins, and **Use the environment's** goes back to it.
A count that is too low makes every visitor read as the proxy, so they share one sign-in rate limit and a few bad sign-ins block everyone. The "too many attempts" message says so and points here. The block on sign-ins lifts after a minute; an admin who is already signed in can fix the count at once.
Example Nginx location block:
```nginx
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Required for SSE streaming
# MCP responses can stream.
proxy_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
```
If using `TRUST_PROXY_HEADERS=true`, ensure your proxy strips any client-supplied `X-Forwarded-For` headers before adding its own.
Each proxy appends its entry to `X-Forwarded-For` and `X-Forwarded-Proto`, so Grimoire reads the entry that many in from the right: the one your own proxy wrote. Whatever a client puts to the left of it is ignored. A header with fewer entries than that (a LAN road with one proxy beside a Cloudflare road with two) is read at its leftmost entry, the one the first proxy wrote.
### Security Checklist
When that entry says `https`, the session cookie is marked `Secure` and the response carries `Strict-Transport-Security: max-age=31536000`. Over plain HTTP neither is sent, and Grimoire never redirects to HTTPS, so a LAN install keeps working.
- **Strong `SECRET_KEY`** — generate with:
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```
Or use Docker Secrets via `SECRET_KEY_FILE`.
- **`SECURE_COOKIES=true`** — must be set when running behind TLS.
- **`BASE_URL`** — set to your public URL; required for OIDC redirects and email links.
- **Registration** — auto-closes after the first user (admin). Re-enable from Settings → Users or send invite links.
- **Session invalidation** — changing or resetting a password bumps `session_version`, evicting all other active sessions. Button also available in Settings → Account.
- **Keep Ollama on an internal network** — both compose files keep Ollama off the host network. Never expose the Ollama port publicly.
- **Default SECRET_KEY warning** — the app logs a `WARNING` on startup if the default dev key is in use.
A hop count higher than the real number of proxies lets a client choose its own address, so count them. With `0`, the headers are ignored and the socket peer is the client: use it when clients reach port 5000 directly.
## Model Management
## Security checklist
Models are managed through Settings → General → Model Management in the web UI. You can:
- Pull new models by name (streams progress via SSE)
- View installed models with size and loaded/unloaded state
- Delete unused models
The app auto-warms user-preferred models on startup (only models already installed — never auto-pulls). The embedding model (`nomic-embed-text`) is auto-pulled on startup if missing.
Recommended models for 2× 8 GB GPU:
- **`qwen3:8b`** — strong reasoning + tools, fits in 8 GB, supports thinking mode
- **`qwen2.5:7b`** — fast, tool-capable, smaller context
- **`llama3.1:8b`** — reliable baseline, widely tested with tools
- **Keep the key.** Forge tokens and the backup destination's S3 secret are sealed under a key derived from `SECRET_KEY`, so a copy of the database alone can't read them. Unset, the instance makes a random key and keeps it in `/data/secret_key`, readable by its owner only; keep that volume. If the key changes (say, a restore onto an instance with a different key), Settings marks those credentials and asks for them again; nothing else breaks. The app warns at startup when `SECRET_KEY` is a public placeholder or `/data` is not a mounted volume, and refuses to start with a placeholder key (`dev-secret-change-me`, `change-me-in-production`).
- **The proxy count** (Settings → Config → Reverse proxy) matches the number of proxies in front of Grimoire, so rate limits and the audit log see the real client and the cookie is `Secure` over HTTPS. The section shows whether it does.
- **Registration** is open only for the first account. Invite people, or open it briefly from Settings.
- **Signing everyone else out**: changing a password ends that user's other sessions; Settings → Account has a button for it too.
- **API keys** are per user, shown once, and can be read-only. Revoke any you no longer use under Settings → MCP Access.
+18 -223
View File
@@ -53,7 +53,7 @@ Parchment is intentionally not pure white — it's slightly warm to feel like ag
| Bronze | `#8B7355` | Secondary action buttons (Cancel-but-not-destructive, alternate paths) |
| Pewter | `#3F4651` | Tertiary/ghost buttons |
**Critical rule:** Action button colors are universal across all apps. A Save button in Scribe and a Save button in Minstrel look identical. Per-app accents do not appear on buttons.
**Critical rule:** Action button colors are universal across all apps. A Save button in Grimoire and a Save button in Minstrel look identical. Per-app accents do not appear on buttons.
### Semantic colors
@@ -71,7 +71,7 @@ Parchment is intentionally not pure white — it's slightly warm to feel like ag
| App | Hex | Mood |
|---|---|---|
| Fabled Scribe | `#5B4A8A` | Dusty violet — ink, manuscripts |
| Fabled Grimoire | `#5B4A8A` | Dusty violet — ink, manuscripts |
| Minstrel | `#4A6B5C` | Forest teal — music, performance |
| Fabled Forge | `#8B5A2B` | Forge bronze — creation, craft |
| Roundtable | `#4A5D7E` | Slate blue — stewardship, infrastructure |
@@ -80,7 +80,7 @@ Parchment is intentionally not pure white — it's slightly warm to feel like ag
**Accent usage rules:**
- The accent appears on the app's wordmark and icon.
- The accent indicates active/current state in nav (the selected page, the active tab).
- The accent is the cursor color and text-selection color in long-form surfaces (Scribe notes, Forge story drafts).
- The accent is the cursor color and text-selection color in long-form surfaces (Grimoire notes, Forge story drafts).
- The accent does NOT appear on primary or secondary action buttons.
- The accent does NOT appear in body text or chrome.
- One accent per app. Don't mix accents within a single app.
@@ -101,6 +101,8 @@ All text-on-surface combinations meet WCAG AA at minimum. Parchment on Obsidian
| Inter | Body, UI, labels | Google Fonts |
| JetBrains Mono | Code, terminal output, monospaced data | Google Fonts |
All three are served by the app itself, from `frontend/src/assets/fonts/`, with every subset Google Fonts publishes for them. A page never asks a font host for anything, so an instance with no internet still draws in its own type. They are under the SIL Open Font License; the licences sit beside the files.
### Why this pairing
Fraunces is a contemporary serif with personality — it has the warmth and authority of a book serif without feeling like costume. It signals "this is considered" without signaling "this is a fantasy product." Inter is the workhorse — neutral, ubiquitous, designed for screens, doesn't compete with the serif. JetBrains Mono is the natural choice for any developer-adjacent product and supports ligatures.
@@ -124,7 +126,7 @@ Fraunces is a contemporary serif with personality — it has the warmth and auth
- **Sentence case everywhere.** Never Title Case for headings, never ALL CAPS except for the Tiny micro-label style.
- **Two weights only:** 400 regular and 500 medium. Never 600 or 700 — they read heavy in dark mode.
- **Fraunces only at 18px and above.** Below that it loses too much detail and feels fragile. For h3 and below, use Inter.
- **Line height** 1.5 for body, 1.3 for headings, 1.7 for long-form reading surfaces (Scribe notes, Forge drafts).
- **Line height** 1.5 for body, 1.3 for headings, 1.7 for long-form reading surfaces (Grimoire notes, Forge drafts).
- **Letter-spacing** at default for everything except the Tiny micro-label, which gets `0.08em` letter-spacing and uppercase styling.
---
@@ -173,7 +175,7 @@ Padding: `8px 16px` for default, `6px 12px` for compact, `10px 20px` for promine
Used for tags, hashtags, code spans, status badges. Background is the accent color at ~15% opacity, text is the accent at full strength. Border-radius 4px, padding `2px 8px`, Inter 11px/500.
In Scribe specifically, hashtags and tags use the dusty violet accent. In Minstrel, they'd use forest teal. The pattern is shared; the color follows the app.
In Grimoire specifically, hashtags and tags use the dusty violet accent. In Minstrel, they'd use forest teal. The pattern is shared; the color follows the app.
### Cards
@@ -272,9 +274,9 @@ Use **Lucide** (https://lucide.dev) as the base icon set — it matches this sty
## Per-app application
### Fabled Scribe (#5B4A8A — dusty violet)
### Fabled Grimoire (#5B4A8A — dusty violet)
A second-brain notes and task management tool. The accent appears in: the wordmark, hashtags and tag pills, the active nav item, text selection color, and the cursor in the editor. Notes are presented on Iron-surfaced cards with generous reading line-height. The hashtag system uses Scribe's accent for visual continuity.
A second-brain notes and task management tool. The accent appears in: the wordmark, hashtags and tag pills, the active nav item, text selection color, and the cursor in the editor. Notes are presented on Iron-surfaced cards with generous reading line-height. The hashtag system uses Grimoire's accent for visual continuity.
### Minstrel (#4A6B5C — forest teal)
@@ -332,7 +334,7 @@ Home server management. The accent appears on: the wordmark, healthy/online stat
}
/* Per-app accent — set ONE of these on the root for each app */
[data-app="scribe"] { --fs-accent: #5B4A8A; }
[data-app="grimoire"] { --fs-accent: #5B4A8A; }
[data-app="minstrel"] { --fs-accent: #4A6B5C; }
[data-app="forge"] { --fs-accent: #8B5A2B; }
[data-app="roundtable"]{ --fs-accent: #4A5D7E; }
@@ -354,220 +356,13 @@ If using Tailwind, extend the theme with these tokens rather than relying on def
---
*Last updated: April 25, 2026. Iterate as the family of apps grows.*
# Grimoire
---
The design system Grimoire renders with is stored in Grimoire itself: **Grimoire**, which inherits **FabledSword**, edited at `/design-systems`. `frontend/src/assets/theme.css` is generated from it, and CI rejects a `var()` the sheet doesn't define. Where this page and the stored system differ, the stored system is right. Grimoire applies the baseline above with these choices:
# Scribe-specific decisions in progress
> This section tracks decisions made while adapting the FabledSword baseline above for Scribe specifically. Items here are *in progress* — once they feel solid, they get folded into the main body of the document (either as Scribe-specific extensions in the per-app section, or as updates to the universal rules where Scribe's needs reveal a gap in the baseline).
*Iteration started: 2026-04-26. Foundation pass shipped 2026-04-27 in `7a9a8b7` (palette, fonts, light mode, action tokens, hardcoded indigo cleanup, warm-gold deprecation). Surface phase shipped 2026-04-27 across `93a3beb` → `3c1ec40` (Lucide migration, Hybrid-rule button reclassification per surface, long-form line-height, two-weights-only). The system is now applied end-to-end; this section will fold into the main body once the result has had time to settle in real use.*
## Decisions made so far
### Accent footprint — Hybrid rule (not Strict)
The doc baseline says the per-app accent only appears on wordmark, active nav, cursor, and text selection — never on action buttons. Scribe currently uses indigo on essentially every interactive surface (CTAs, scrollbars, glows, borders, focus rings). Hard-cutting to the doc baseline would lose too much identity in one swing.
**Hybrid rule:** the accent reserves a slightly larger footprint than the doc baseline, but still much smaller than today.
- **Accent (dusty violet) lives on:** wordmark; active nav; cursor and text selection in editor surfaces; tags/pills/wikilinks; in-progress task badge; focus rings; **brand-moment CTAs** — chat Send, "Create note" empty-state CTA, journal Send, "Start journaling" empty-state.
- **Moss (sage-green primary) lives on:** Save / Submit / Confirm in forms and modals; generic affirmative actions where the button just means "do this thing" with no brand pretense.
- **Bronze (secondary):** Cancel-but-not-destructive, alternative paths.
- **Oxblood (destructive):** Delete / Remove (paired with an icon).
- **Pewter ghost:** tertiary actions, "later", "skip", "see also".
**Rule of thumb:** if the user is engaging with a *Scribe-feature moment* (sending a chat, opening a fresh note, jumping into the journal), accent. If they're just *operating the software* (saving an edit, confirming a dialog), Moss.
### Light mode — warm parchment, matched aesthetic
The doc is dark-only. Scribe today supports both light and dark, and we keep both. The light mode is derived to *match* the dark mode aesthetic rather than defaulting to system white-and-ink.
- Page background: in the `#F5F1E8` warm cream family (specific values TBD)
- Cards: near-white but slightly tinted
- Text: deep ink `#14171A` (mirroring Obsidian)
- Accent: same dusty violet `#5B4A8A` (works on both themes)
The metaphor stays consistent across themes: ink on aged paper (light) ↔ parchment text on graphite (dark). Light mode is *not* the system standard look.
**Known downside:** warm parchment backgrounds can fight with embedded color content. Mitigation: code blocks get a slight cool wash in light mode specifically, to keep syntax highlighting readable.
### Status and priority palette — extend the doc's semantic set
The doc's semantic colors (Success / Warning / Error / Info / Destructive) are leaner than what Scribe needs for task management. Rather than running a parallel palette, Scribe extends the doc by mapping its status/priority tokens onto doc primitives where they fit and defining new app-level tokens for the rest.
**Status (task lifecycle):**
| Token | Color | Source | Logic |
|---|---|---|---|
| `status-todo` | Pewter `#3F4651` | doc | Neutral, "not started yet" |
| `status-in-progress` | dusty violet `#5B4A8A` | accent | Active = brand moment per Hybrid |
| `status-done` | Moss `#4A5D3F` | doc Success | Affirmative completion |
| `status-cancelled` | Ash `#9C9A92` | doc | Faded, "let go" |
| `status-paused` | Warning `#8B6F1E` | doc | Stalled, needs attention — replaces the old warm gold treatment |
**Priority (loudness scale):**
| Token | Color | Source | Logic |
|---|---|---|---|
| `priority-low` | Info `#3D5A6E` | doc | Cool, FYI — quietest end of the spectrum |
| `priority-medium` | Warning `#8B6F1E` | doc | Golden, mid-attention |
| `priority-high` | Error `#C04A1F` | doc | Terracotta, urgent |
| `priority-none` | Vellum/Ash | doc | No signal |
The priority row reads as a clean cool→warm gradient (slate blue → golden brown → terracotta), which matches the semantic loudness — coherence the current ad-hoc palette doesn't have.
**Other functional tokens:**
| Token | Color | Logic |
|---|---|---|
| `wikilink` | dusty violet | Editorial brand moment per Hybrid |
| `overdue` | Error `#C04A1F` | Same as priority-high — overdue IS a priority signal |
| `toast-success` | Moss | doc semantic |
| `toast-error` | Error | doc semantic |
| `toast-info` | Info | doc semantic |
| `tag-bg` / `tag-text` | accent at 15% / accent | Per doc pill recipe |
Each token gets a `*-bg` companion at low alpha (matching the existing pattern in `theme.css`).
**Removed:** the warm gold accent (`--color-accent-warm: #b8860b`). Its two jobs split:
- Dates and timestamps (knowledge cards, event details, chat) → use `text-secondary` instead. Dates are metadata, not a brand surface; muted is the correct register.
- Paused project status → use the new `status-paused` (Warning `#8B6F1E`) row above. Same golden-brown family, semantically aligned.
### Typography — adopt the doc's stack and scale
Adopt the doc's type stack and scale verbatim, with one deferred verification (long-form line-height in practice).
- **Body font: Inter.** Replaces Scribe's current system-stack body font. Doc-defined; no Scribe-specific divergence.
- **Type scale:** as in the doc table — Display 40 / H1 32 / H2 24 / H3 18 / Body 15 / Body small 13 / Label 12 / Code 13 / Tiny 11.
- **Two weights only:** 400 regular, 500 medium. No 600/700 (reads heavy in dark mode and against the muted palette).
- **Family rules:** Fraunces at 18px+ only (Display, H1, H2). H3 and below = Inter. Code = JetBrains Mono.
- **Line height:** 1.5 body, 1.3 headings, **1.7 for long-form reading surfaces** (notes, journal entries).
- **Sentence case for everything**, except the Tiny micro-label style which gets uppercase + 0.08em letter-spacing.
Mechanical rollout — value swaps in `theme.css` plus loading Inter and JetBrains Mono from Google Fonts (Fraunces is already loaded).
### Light mode — concrete palette
Fills in the warm-parchment direction picked earlier. Treat these as starting values; tune in practice.
| Token | Hex | Role |
|---|---|---|
| Page bg | `#F5F1E8` | Warm cream — the "paper" |
| Card bg | `#FBF8F0` | Near-white, slightly warm — raised surfaces |
| Inset bg (inputs, code) | `#EFEAE0` | Slightly darker than page, "inset" feeling |
| Text primary | `#14171A` | Deep ink — Obsidian inverted |
| Text secondary | `#5A5852` | Warm mid-grey |
| Text muted | `#9A9890` | Warm light grey for hints/metadata |
| Border default | `#D9D6CE` | Warm light pewter, hairline weight |
**Code-block exception:** in light mode specifically, code blocks use a slight cool wash (e.g. `#EBEDF0`) instead of the warm inset bg, so syntax highlighting reads cleanly. This is the mitigation for the "warm bg fights colored content" downside.
The accent (`#5B4A8A` dusty violet), Moss, Bronze, Oxblood, and the semantic color set are **identical across themes** — only the surface and text palettes flip.
### Chat-bubble codification — keep the Illuminated Transcript pattern
The existing chat-bubble pattern (informally called "Illuminated Transcript") gets written into the design system as a documented chat component. Other apps in the family that add a chat surface inherit the pattern; Scribe's existing implementation continues to work with only color shifts.
**User bubble (whisper):**
- Background: transparent
- Border: 0.5px Pewter (was: indigo-tinted)
- Text color: secondary (Vellum dark / `#5A5852` light)
- Right-aligned, rounded except bottom-right (subtle "from-me" tail)
**Assistant bubble (lit):**
- Background: card surface (Iron dark / `#FBF8F0` light)
- Border: none on top/right/bottom; **2px solid accent (dusty violet) on left edge only**
- Box-shadow: accent-tinted glow + standard depth shadow (formula: `0 4px 28px rgba(<accent>, 0.14), 0 2px 8px rgba(0,0,0,0.4)` in dark; lower alphas in light)
- Text color: primary (Parchment dark / Obsidian-inverted light)
- Left-aligned, rounded except bottom-left
The 2px-accent left edge is the "illumination" — like an illuminated capital in a manuscript. The shadow is the lift. Together they make the assistant bubble read as the *primary* voice, while the user bubble is the *margin note*.
**Inline tool-call cards (`ToolCallCard`)** rendered inside an assistant bubble do NOT get their own border (per the border philosophy — the bubble already contains them). They use a slight surface tint to differentiate.
### Iconography — adopt Lucide, enforce a scale
Scribe currently hand-inlines SVG paths in 16+ Vue files, with 5 different stroke weights and 8+ different sizes. The visual style is already outline + rounded caps + `currentColor` stroke (matches the doc's intent), but there's no shared source and no scale discipline.
**Migration policy:**
1. **Install `lucide-vue-next`** as the icon source. Replace hand-inlined SVGs with imported components. Single source of truth.
2. **Strict size scale: 16px and 24px only.** Today's mix of 12/13/14/15/17/18/20 collapses to those two. 16 for inline-with-text and small affordances; 24 for nav and primary actions.
3. **Stroke weight per the doc: 1.5 at 24px, 1 at 16px.** Lighter than the current default of 2 — reads more refined, matches the muted palette philosophy. Overrides Lucide's default.
4. **Outline by default; filled only for active/selected state.** Introduces a new affordance Scribe doesn't currently use — bookmark/pin/star icons can switch outline → filled to indicate active state. Reserve filled style strictly for this.
5. **No emoji in chrome.** Replace the 3 files' emoji usage in UI labels/buttons/badges/empty states with Lucide equivalents. Emoji remain fine in *user content* (note bodies, chat messages the user typed).
Work cost: ~30-60 individual icon swaps across the 16 files. Mechanical; doesn't require redesign of any component.
### Voice and tone — adopt principles, defer formal audit
The doc's voice register applies to Scribe (understated mythic — plain for functional UI, flavored for empty/error/loading states). No formal sweep of every UI string yet.
**Approach:** apply the voice opportunistically as components are touched in the polish pass — when redesigning a settings tab, an empty state, or an error toast, rewrite the copy at the same time using the doc's register and examples table as the guide. A standalone audit pass is deferred unless drift becomes visible.
### Border philosophy — structural, not decorative
The doc treats borders as *structural* (Pewter neutral hairlines that say "boundary"), not decorative (Scribe today uses indigo-tinted borders that say "branded edge"). That principle suggests removing borders in places where surface tint and spacing already communicate separation.
**Borders to remove:**
- List rows (NotesListView, TasksListView, conversation history) — surface contrast + spacing should separate rows; current border reads as "boxed-in"
- Inline `ToolCallCard` inside chat bubbles — the bubble is already a container; an extra border feels like double-wrapping
- Filter chips and search-bar pills with a background tint — background does the work
- Empty-state callouts with dashed/bordered "nothing here yet" boxes — tinted background reads cleaner
**Borders to keep (genuinely structural):**
- Standalone card containers (Notes viewer, Task viewer, the new daily prep card)
- Modal / dialog edges
- Code blocks (separates content type, not just space)
- Focus rings (accessibility)
- Major section dividers within a panel
Border weight is not load-bearing for Scribe — happy to use the doc's 0.5px hairline default; the *placement* discipline matters more than the weight.
## Open threads (next iterations)
### Foundation pass — shipped 2026-04-27 (`7a9a8b7`)
Mechanical token + font + light-mode rewrite of `frontend/src/assets/theme.css`, plus a sweep of hardcoded indigo and `--color-accent-warm` references across ~14 component files. Action tokens (`--color-action-primary` Moss, `--color-action-secondary` Bronze, `--color-action-destructive` Oxblood, `--color-action-ghost-border` Pewter) are defined but not yet applied — buttons still flow through `--color-primary` and read as dusty-violet gradients in the meantime, by design. Spec lives at `docs/superpowers/specs/2026-04-27-design-system-polish-foundation-design.md` (gitignored, local-only).
### Surface phase — shipped 2026-04-27 (`93a3beb` → `3c1ec40`)
Bundled as Hybrid (option C from the brainstorm): Lucide cross-cutting first, then surface-by-surface for the judgment work. Spec lives at `docs/superpowers/specs/2026-04-27-design-system-polish-surface-design.md` (gitignored, local-only). Seven PRs landed on `dev`:
| PR | Commit | Surface | Notes |
|---|---|---|---|
| 1 | `93a3beb` | Lucide cross-cutting | 60 hand-inlined SVGs across 15 files → `lucide-vue-next`. Every chrome icon at 16 or 24. Emoji-as-icons (`✕`, `✓`, `🎤`, `📎`, `↻`, `↑`, `&times;`, `☀`/`☾`) swept across the chrome. AppLogo wordmark and the GraphView D3 mount kept as the legitimate exceptions. |
| 2 | `3d916d7` | Journal | Buttons audited; `↻` weather refresh → Lucide `RotateCcw`; assistant bubble line-height bumped; redundant `.journal-title` font-family dropped; dead `.news-section` CSS removed. |
| 3 | `4192a64` | Chat | `.message-content` long-form 1.7 line-height on assistant bubbles; ToolCallCard outer border removed (bubble already contains it; error state moved to a left-edge accent); bulk-delete recolored to Oxblood with Trash2 icon; `.bulk-link` accent → muted text. |
| 4 | `efb3534` | Knowledge cluster | `.prose` line-height bumped to 1.7 globally so all reading surfaces inherit. Save → Moss; Delete → Oxblood + Trash2; Edit / Advance → Moss; Convert / Share → Bronze. |
| 5 | `ff498ce` | Project + Workspace | Project save panel → Moss; milestone confirm/cancel → Moss/Bronze; modal-btn-danger and per-row delete affordances → Oxblood; "Open Workspace" stays accent (project-surface brand moment). |
| 6 | `541e2ed` | Settings | Densest button surface — every `.btn-save`, `.btn-primary`, `.btn-secondary`, `.btn-danger`, `.btn-danger-outline`, `.btn-toggle-open` reclassified per Hybrid; missing `.btn-danger` style block added (was unstyled before). |
| 7 | `3c1ec40` | Edge surfaces | Calendar `New Event` → Moss; EventSlideOver Save/Cancel/Delete reclassified; HomeView hero CTA stays accent (brand moment); two-weights-only sweep across Header/Home/Calendar/Graph. |
**Cross-cutting changes folded in as the work touched files:**
- Long-form 1.7 line-height on `.prose` (PR 4) — applies to Note viewer, Task viewer, chat assistant bubbles, anywhere markdown renders into a reading surface.
- Two-weights-only (400 + 500) — every `font-weight: 600` and `700` snapped to `500` across all surface PRs.
- Hardcoded `--color-danger` in destructive button contexts → `--color-action-destructive` (Oxblood). `--color-danger` (Error terracotta) preserved for validation/error messages, per the doc's distinction between Error and Destructive.
- Adjacent `&times;` / `✕` / unicode-arrow emoji swept opportunistically as files were touched (PRs 5, 7).
### Out of scope — deferred indefinitely
Items deliberately not addressed in this round; revisit when a real need surfaces:
- Lucide stroke-weight overrides (doc spec: 1.5 at 24, 1 at 16; current: Lucide default 2). Touched components if they read too heavy in practice.
- Filled-as-active icon state — no current affordance uses it; introduce when bookmark/pin/star toggles are added.
- Type-scale / spacing-scale CSS variables — components keep literal values.
- Token rename to `--fs-*` namespace — Scribe is the only FabledSword app sharing this codebase.
- FabledSword lockup placement — waiting on the actual heraldic mark to be drawn.
- Standalone voice/tone audit across every UI string — opportunistic-only; full sweep deferred unless drift becomes visible.
- A handful of editor utility buttons (`.btn-suggest-tags`, `.btn-link-all`, AI assist generate/proofread/accept/reject set, etc.) — currently ghost-styled and visually compliant; revisited only if they read off in practice.
### Open threads
*New threads will accumulate here as gaps surface in real use.*
- **Accent footprint.** Dusty violet marks Grimoire's own moments: the wordmark, active navigation, the editor's cursor and selection, tags and wikilinks, in-progress status, focus rings, and the call to action that starts something new (a first note, say). Operating the software uses the action colours: Moss to save or confirm, Bronze for alternatives, Oxblood with an icon to delete, Pewter ghost for tertiary actions.
- **Light and dark.** Both are supported. Light mode is warm parchment (`#F5F1E8` page, `#FBF8F0` cards, `#14171A` text) rather than white, with a cool wash on code blocks so syntax colours stay readable. The accent, action and semantic colours are the same in both.
- **Status and priority.** Status: todo Pewter, in progress the accent, done Moss, cancelled Ash, paused Warning. Priority runs cool to warm: low Info, medium Warning, high Error. Overdue uses Error. Each has a low-alpha `-bg` companion.
- **Type.** Inter for UI and body, Fraunces at 18px and up (display, H1, H2), JetBrains Mono for code. Two weights only, 400 and 500. Line height 1.5 for body, 1.3 for headings, 1.7 on reading surfaces such as a note.
- **Icons.** Lucide, at 16px inline and 24px for navigation and primary actions; no emoji in the interface.
- **Borders are structural.** Cards, dialogs, code blocks, focus rings and major dividers get borders; list rows, chips and empty states are separated by surface tint and spacing instead.
+48 -140
View File
@@ -1,179 +1,87 @@
# Development
## Workflow
All development is Docker-based. Do not install Python or Node dependencies locally.
## Running it
```bash
# Start the full stack (app + PostgreSQL + Ollama)
docker compose up --build
docker compose -f docker-compose.dev.yml up --build # app + PostgreSQL at http://localhost:5000
docker compose -f docker-compose.dev.yml up --build app # rebuild after a change
docker compose -f docker-compose.dev.yml down -v # stop and wipe the database
```
# Rebuild after backend changes (frontend changes require rebuild too)
docker compose up --build app
The image builds the frontend, so a frontend change needs a rebuild too. For quicker frontend work, run Vite against the running app; it proxies `/api` to port 5000:
# Reset everything (wipes database)
docker compose down -v && docker compose up --build
```bash
cd frontend && npm ci && npm run dev # http://localhost:5173
```
# Run checks (lint, format, typecheck, tests)
make check
## Checks
# Individual checks
CI runs these on every push; locally they need `uv sync --extra dev` and `cd frontend && npm ci`.
```bash
make lint # ruff check src/
make fmt # ruff format src/
make typecheck # vue-tsc --noEmit
make test # pytest tests/
make check # all three
```
## Frontend Hot Reload
Unit tests run without a database. Tests marked `integration` need a real PostgreSQL and run in CI's integration job, against a schema built by the real migrations.
The Docker setup does not include Vite's hot-reload dev server. After frontend changes, rebuild the image. For faster iteration during active frontend work, you can run Vite locally:
## Database migrations
The container runs `alembic upgrade head` on every start, after waiting for the database to accept connections.
```bash
cd frontend
npm install
npm run dev # Vite dev server at http://localhost:5173
docker compose -f docker-compose.dev.yml exec app alembic revision -m "short_description"
```
Point the Vite dev server at the backend by setting `VITE_API_BASE_URL=http://localhost:5000` (or configure `vite.config.ts` proxy).
- Number revisions sequentially (`0123_...`). Revision ids must fit in 32 characters, the width of `alembic_version.version_num`.
- Write both `upgrade()` and `downgrade()`.
- Adding a value to a `CHECK`-constrained column means dropping and re-adding the constraint in the same migration.
- Keep migrations to schema changes. Filling a derived table from a large one belongs in a background backfill after startup, where its cost doesn't hold up the boot.
- A migration that depends on application code (a key derivation, a serialiser) copies what it needs rather than importing it, so a later edit can't change what an old migration does.
## Database Migrations
## The plugin
Alembic migrations run automatically on container startup (`alembic upgrade head` in the `CMD`).
`plugin/` ships straight from git: installs fetch it through `.claude-plugin/marketplace.json`, and there is no build step. After changing anything under `plugin/` or `.claude-plugin/`, run `make mint-plugin` before committing. Claude Code only refreshes an installed plugin when its version changes, and CI fails a plugin change that didn't bump it.
To create a new migration:
```bash
# Inside the running app container
docker compose exec app alembic revision -m "description_of_change"
# Edit the generated file in alembic/versions/
```
The hooks must run on a bare machine: POSIX shell, awk, sed and the usual coreutils, plus `git` and `curl`. They fail open, so a slow or unreachable Grimoire never blocks Claude Code.
Migration conventions:
- Use raw SQL with `IF NOT EXISTS` guards for idempotency
- Use `DO $$ BEGIN CREATE TYPE … EXCEPTION WHEN duplicate_object THEN NULL; END $$` for enum types
- Number migrations sequentially (e.g. `0027_add_something.py`)
- Always provide both `upgrade()` and `downgrade()`
## CI and releases
## CI/CD
CI runs on Gitea Actions (workflow in `.forgejo/workflows/ci.yml`) in the shared `ci-python:3.14` image ([`ci-requirements.md`](../ci-requirements.md) lists what it relies on). Six jobs: TypeScript typecheck, plugin hooks, Python lint (plus design-token and dangling-style checks), unit tests, integration tests, and the image build, which waits on all of them except the plugin job.
### Pipeline
| Push | Image tags |
|------|-----------|
| `dev` | `:dev`, `:<sha>` |
| `main` | `:latest`, `:<sha>` |
| a `v*` tag | `:latest`, `:<version>`, `:<sha>` |
CI runs on Forgejo Actions, consuming the shared
[`ci-python:3.14`](https://git.fabledsword.com/bvandeusen/CI-runner) image
via `container.image` (Python 3.14 + Node 24 + ruff + uv + Docker CLI):
`:<sha>` never moves, so it is the rollback point. Images publish to `git.fabledsword.com/bvandeusen/fabledgrimoire`, with a registry layer cache at `:cache`. The build needs the `REGISTRY_USER` and `REGISTRY_TOKEN` (a token with `write:packages`) repository secrets.
| Trigger | Jobs | Docker tags pushed |
|---------|------|--------------------|
| Push to `dev` | typecheck + lint + test → build | `:dev`, `:<sha>` |
| Tag `v*` on `main` | typecheck + lint + test → build | `:latest`, `:<version>`, `:<sha>` |
| Push to `main` | typecheck + lint + test | (no build) |
Work happens on `dev`. A release merges `dev` into `main` with a plain merge commit; a dated `vYYYY.MM.DD.HHMM` tag, matching the version the image reports, can mark it.
### Release Process
1. Work on `dev` branch — CI validates on every push
2. When ready, open a PR from `dev` → `main` in Forgejo
3. Merge the PR
4. Create a release via the Forgejo UI on `main` with a `v*` tag (e.g. `v26.03.23.1` — CalVer: `YY.MM.DD.N`)
5. The tag push triggers CI → build job pushes `:latest` + `:<version>` Docker images
6. After merging to main, sync dev back:
```bash
git checkout dev && git merge main && git push origin dev
```
### Runner
CI jobs schedule against the `python-ci` runner label and run inside the
shared `git.fabledsword.com/bvandeusen/ci-python:3.14` image (see
`ci-requirements.md` for what this project relies on from the image).
The runner deployment lives outside this repo; image bumps happen in
[CI-Runner](https://git.fabledsword.com/bvandeusen/CI-runner) via Renovate.
`infra/runner-compose.yml` + `infra/act-runner-config.yml` document the
runner-host deployment shape; the source of truth is the deployed
config on the runner host.
### Docker Registry
Images pushed to: `git.fabledsword.com/bvandeusen/scribe`
Cache tag: `:cache` (reduces build time ~80%)
Required secrets (repo → Settings → Secrets → Actions):
- `REGISTRY_USER` — Forgejo username
- `REGISTRY_TOKEN` — Forgejo PAT with `write:packages` scope
## Migration Chain
Current migration sequence (all idempotent raw SQL):
```
0001 create_notes_table
0002 create_tasks_table
0003 task_note_companion (data migration)
0004 merge_tasks_into_notes
0005 add_chat_tables
0006 add_settings_table
0007 add_title_and_updated_at_indexes
0008 add_users_and_user_id
0009 add_message_status
0010 add_app_logs_table
0011 add_password_reset_tokens
0012 add_invitation_tokens
0013 add_tool_calls_to_messages
0014 add_note_embeddings
0015 add_oauth_fields
0016 add_image_cache
0017 add_projects
0018 add_push_subscriptions
0019 add_events (dead code — internal CalDAV/Radicale table; Radicale was removed)
0020 add_milestones
0021 add_task_logs
0022 add_note_versions_and_drafts
0023 add_tags_to_note_versions
0024 add_session_version
0025 add_sharing_and_notifications
0026 add_briefing_tables
0027 add_api_keys
```
**Important:** Do NOT use `op.create_table()` or `sa.Enum()` — SQLAlchemy's event system can fire `CREATE TYPE` even with `create_type=False`, causing failures on re-run. Always use raw SQL with `IF NOT EXISTS` / `DO $$ BEGIN ... EXCEPTION WHEN duplicate_object` guards.
## Project Conventions
## Conventions
### Backend
- Services: `async with async_session() as session:` — import from `scribe.models`
- No `scribe.database` module
- Blueprint per resource: `routes/notes.py`, `routes/tasks.py`, etc.
- All business logic in `services/`; routes are thin wrappers
- Permission checks via `services/access.py` — never inline ownership checks in routes
- Services open their own session: `async with async_session() as session:` (from `grimoire.models`).
- Routes and MCP tools stay thin; the logic lives in `services/`, shared by both.
- Access checks go through `services/access.py`, never inline ownership checks.
- Every wait on something outside the process has a deadline.
### Frontend
- API calls via `frontend/src/api/client.ts` typed helpers (`apiGet`, `apiPost`, `apiPatch`, `apiDelete`)
- Pinia stores for shared state; local `ref()` for component-only state
- Composables in `composables/` for reusable behaviour (autosave, keyboard nav, tag suggestions, …)
- Views are page-level components in `views/`; reusable UI in `components/`
- API calls go through the typed helpers in `frontend/src/api/client.ts`.
- Pinia stores for shared state, `ref()` for state local to a component; composables for reusable behaviour.
- Colours, spacing and type come from the design system's tokens in `theme.css`, which is generated, so CI rejects an unknown `var()`.
### Commit Style
### Commits
```
type(scope): short description
type(scope): what the change does
Longer body if needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Why, and anything a reader tracing this change later needs.
```
Types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`
Scopes: feature area (e.g. `chat`, `journal`, `mcp`, `notes`)
## Testing
```bash
# Run all tests
make test
# Run specific test file
docker compose exec app /opt/venv/bin/pytest tests/test_auth.py -v
```
Tests are in `tests/`. They run against a real PostgreSQL instance in CI (not mocked). Keep tests integration-style where possible — mock failures have historically masked real migration bugs.
Types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`. Comments in the code describe how it works today; the history of a change belongs in its commit message.
+84 -129
View File
@@ -1,177 +1,132 @@
# Features
Scribe is a self-hosted work system-of-record for software projects, built to be
driven by Claude Code. There is **no in-app LLM** — Claude is the sole assistant,
reaching Scribe through a built-in MCP endpoint and a bundled Claude Code plugin. The
web UI is a clean surface for humans to read and edit the same data.
Grimoire is a self-hosted system of record for the work you do with Claude Code. Claude records what it learned, decided and built, and that record reaches the next session, so a solution is recalled rather than reinvented. There is no language model inside Grimoire: Claude reaches it through the MCP endpoint and the Claude Code plugin, and the web UI is where people read and edit the same records.
## Notes
Write in Markdown with a live-preview editor (Tiptap/ProseMirror). Headings, bold,
italic, lists, code blocks, and task checklists render inline. A slash-command menu
(`/`) inserts common blocks.
A Markdown editor (Tiptap) with headings, lists, code blocks and checklists inline, and a `/` menu for blocks.
- **Wikilinks** — Link notes with `[[Title]]` or `[[Title|Display Text]]`. Clicking
navigates to (or auto-creates) the referenced note; the editor suggests existing
titles while you type `[[`. Backlinks appear in the note viewer sidebar.
- **Tags** — First-class `ARRAY[text]` column with autocomplete. Hierarchical tags
(`area/backend`) supported — filtering by `area` matches all `area/*` children.
- **Version history** — Every body edit snapshots a version (up to 20 per note).
Browse, diff, and restore from the editor's History panel.
- **Draft recovery** — In-progress edits persist across page loads and are restored
when you reopen a note.
- **Convert freely** — Turn a note into a task (sets `status=todo`) or back again.
- **Wikilinks**: `[[Title]]` or `[[Title|text]]`, suggested as you type; backlinks show in the viewer.
- **Tags**, with autocomplete. Hierarchical tags (`area/backend`) match their parent's filter.
- **Version history**: browse, diff and restore earlier versions of a note.
- **Draft recovery**: unsaved edits survive a reload.
## Tasks and Issues
## Tasks
Tasks carry status (`todo` → `in_progress` → `done`/`cancelled`), priority
(`none`/`low`/`medium`/`high`), due date, milestone assignment, and a parent task
(sub-tasks). Notes and tasks share one model — a task is a note with a status.
A task is a note with a status (`todo`, `in_progress`, `done`, `cancelled`), plus priority, due date, milestone and parent task.
- **Work logs** — Append timestamped progress entries (with optional duration) to a
task without rewriting its body; shown chronologically in the task view.
- **Issues** — A task whose `kind` is corrective: a problem you fixed or are fixing,
with symptom → root cause → fix in the body. An issue can link the task it arose
from and the System(s) it touches.
- **Recurring tasks** — An interval or calendar recurrence rule spawns the next
occurrence when a task is completed (a background job drains due spawns).
- **Sub-tasks** — Any task can have children via `parent_id`; the viewer shows them
inline.
- **Kinds**: `work`; `issue`, for corrective work, which can link the task it arose from; and `spike`, a time-boxed investigation whose output is an answer.
- **Work logs**: timestamped entries that record progress without rewriting the body. They are what the next session reads first.
- **Claims**: a session that logs on a task holds it, so after a context compaction the work comes back to that session, and other sessions can see who is on it.
- **Recurring tasks** spawn their next occurrence on a schedule.
## Projects and Milestones
## Projects, milestones and systems
- **Projects** — Group related notes and tasks. Title, description, goal, status
(`active`/`paused`/`completed`/`archived`), and a colour.
- **Milestones** — Ordered stages within a project. A milestone is also the home of a
**plan** — its body holds the design (Goal/Approach/Verification) and its child
tasks are the steps. Completion percentage is shown on the project page.
Milestones are searchable by meaning (`search(content_type="milestone")`), and an
agent starting a plan (`start_planning`, `create_milestone`) is handed the active
milestone that already has its title or reads as the same plan, so steps are added
there rather than to a parallel plan. The match threshold is in Settings.
- **Kanban view** — `/projects/:id` groups tasks by milestone in a column layout with
status-advance buttons on the cards.
- **Projects** carry a goal, a status and a colour, and group everything else. Entering a project gives Claude a short primer: its goal, recent plans and their next steps, open tasks, its systems and its design system.
- **Milestones are plans**: a body holding the design (goal, approach, verification) and ordered step tasks. Starting a plan that matches an active milestone hands back that milestone instead of opening a second one.
- **Systems** are a project's named areas ("auth", "retrieval"). Records are tagged with them, each has a charter saying what belongs there, and each maps to a canonical area shared across projects.
- **Duplicate gates**: creating a record that reads like an existing one returns the existing one to update instead.
## Systems
## Rules, lessons and moments
A **System** is a per-project, reusable, self-describing subsystem or area (e.g.
"auth", "billing"). Associate any note, task, or issue with a System so research,
build-work, and fixes for the same area line up and recurring problem-spots surface.
- **Rulebooks → topics → rules.** A rule in a rulebook applies in every project; a project rule applies only there. A project that departs from a global rule says so with an `overrides` relation.
- **Rules arrive by retrieval.** Nothing is preloaded: a rule reaches a session when what Claude is about to do matches its trigger (`when_to_apply`), whether that's the operator's message, a command or a file being written.
- **Moments**: a rule about *when* something is done (finishing a task, reporting back, pushing) can be mounted on a moment of work, and arrives whenever that moment happens, whatever the words look like. Settings → General shows which actions reach each moment.
- **Versions and verification**: every edit to a rule keeps the text it replaced. A rule that asserts a fact about someone else's software can say how to check it and when it stops being true, and a sweep lists rules due for a check.
- **Preferences** are rules with the force of guidance rather than obligation.
- **Lessons** record something learned the hard way, keyed to the situation where it applies, and link to the rules they are instances of.
- **Reply shapes**: the default shape of Claude's replies (completion report, plan for review, question) arrives at the moment it applies. Settings → General shows them.
## Rules and Rulebooks
## Snippets and the shape ledger
Scribe stores the operator's engineering and workflow **rules** so Claude follows them
across sessions.
- **Snippets** are recorded prior art: a reusable piece of code with when to reach for it, its signature and its canonical location in a repo. Claude searches them before building and is shown matching ones before it writes a file.
- **Forge integration**: with a git forge connected, Grimoire checks a snippet's cached body against its source and marks it current or diverged. Push webhooks keep that fresh.
- **The shape ledger**: Grimoire extracts the code shapes from a bound repo and tracks how each is accounted for: an instance of a snippet, a deliberate variant with its reason, or a one-off. Coverage shows what is still unclassified.
- **Rulebooks → topics → rules** — Rules are grouped by topic inside a rulebook.
- **Rules arrive by retrieval** — Nothing is preloaded. A rule reaches a
session when what the agent is about to do matches its trigger: a command,
a file being written, or the operator's own message. `when_to_apply` is
therefore the field that decides whether a rule is ever seen.
- **Global or project scope** — A rule in a rulebook is global: it applies in
every project. A project rule applies to that project only. Retrieval honours
the difference, so a session sees global rules plus its own project's, never
another project's. A project that departs from a global rule writes its own
and links it with an `overrides` relation.
## Family canon
## Stored Processes
Projects on the same platform (Android apps, Go services, ...) share good ideas. An idea promoted to the family's canon reaches every project on that platform, and each answers it: adopted, a variant with its reason, or owed (filed as a task). `/family` shows the ideas and every project's answers.
Reusable saved prompts (a note with `note_type=process`) — e.g. a drift-audit or a
DRY pass. The bundled plugin syncs each Process into a local Claude Code skill stub
(`/scribe:sync`) that auto-surfaces by relevance and fetches the live procedure on
demand.
## Design systems
## Search and Knowledge Injection
Tokens (colour, type, space, motion, ...) and written guidance, with inheritance: a project's design system can extend a house style and record where it departs. Grimoire generates a stylesheet from it, checks code snippets against it, and gives Claude the values to use before it writes UI. `/design-systems` edits them.
- **Semantic search** — pgvector-backed similarity search over notes and tasks
(in-process `fastembed` embeddings; no external model).
- **Proactive knowledge-injection** — the plugin's `UserPromptSubmit` hook surfaces a
short, high-confidence menu of maybe-relevant note *titles* into Claude's context
each turn; Claude pulls a full body only when it judges it relevant. Gated so it
stays quiet on most turns and never repeats within a session.
## Stored processes
## Knowledge Graph
Saved procedures, such as a drift audit or a DRY pass. The plugin syncs each one into a Claude Code skill (`/grimoire:sync`), which fetches the live procedure when it is invoked.
`/graph` renders notes, tasks, and tags as a D3 force-directed graph. Tag nodes
cluster notes that share tags; invisible project hubs attract project members. Physics
controls (repulsion, link distance/strength, hub pull, gravity); click a node to peek,
click a tag to filter.
## Search and context injection
- **Semantic search** over notes, tasks, rules, milestones and systems, with embeddings computed in-process (no external model service). It falls back to keyword search if the embedder is unavailable.
- **Context injection**: the plugin's hooks show Claude a short menu of possibly relevant records at the moments they matter: rules and prior art when a prompt arrives, before a command or a file write, and when a moment of work is reached. Claude opens a full record only when it judges it relevant.
- **Tuning**: each surface has its own thresholds and budget, in Settings → General. Every change is logged with its reason, and retrieval telemetry shows what was surfaced and what was opened.
## Claude via MCP and the plugin
The whole store is reachable by Claude through a built-in **MCP endpoint at `/mcp`**
(Bearer-auth with an API key). The **Scribe Claude Code plugin** (shipped in this
repo) wires it up:
The whole store is reachable through the MCP endpoint at `/mcp`, authenticated with an API key. The Claude Code plugin in this repo adds:
- a `SessionStart` hook that injects active-project
context so Scribe surfaces without being asked (fail-open if Scribe is unreachable);
- universal process-skills — writing-plans, systematic-debugging, verification,
brainstorming — that route their output into Scribe;
- your saved Processes auto-surfaced as skills.
- hooks for session start, each prompt, before and after tool calls, the end of a turn, compaction and session end, all failing open if Grimoire is unreachable;
- a project fence: a session belongs to the project its starting directory is bound to. Toward any other project it can read anything and file an issue. Editing that project's files, committing or pushing in its repo, or changing its records first asks you, in every permission mode;
- skills for the working practices: using Grimoire, writing plans, systematic debugging, verification, brainstorming, reporting back, reusing code, shape accounting and family canon;
- your stored processes as skills.
See [API Keys & MCP](api-keys-and-mcp.md).
## Sharing and Collaboration
## The web UI
- **Share** — Share any project, note, or task with users or groups at
`viewer`/`editor`/`admin` levels from the viewer/project toolbar.
- **Groups** — Admins create platform-wide groups and assign `member`/`owner` roles;
share a resource with a group in one action.
- **Shared with me** — `/shared` lists incoming shares with permission badges.
- **Notifications** — An in-app bell (unread count, polled) fires when a project or
note is shared with you or you're added to a group.
- **Dashboard**: what to work on next. **Knowledge** browses everything.
- **Graph** (`/graph`): notes, tasks and tags as a force-directed graph, with physics controls and filtering by tag.
- **Trash**: deleted records wait here until retention purges them, and can be restored.
- **Installable** as a desktop or mobile app.
Every read and mutation is scoped by owner + direct shares + group shares.
## Sharing
## Data Export and Backup
Share a project, note or task with users or groups, as viewer, editor or admin. Admins manage groups. `/shared` lists what has been shared with you, and an in-app bell notifies you of new shares. Every read and write is checked against ownership and shares.
- **Personal export** — download all your notes/tasks as a Markdown ZIP (YAML
frontmatter) or a JSON array.
- **Admin backup** — full application backup/restore (format v4) with ID remapping on
restore for cross-instance migration.
## Export and backup
## Progressive Web App
- **Personal export**: your notes and tasks as a Markdown ZIP (with YAML frontmatter) or JSON.
- **Admin backup and restore**: a restore rolls the whole instance back to one of its own backups, after saving the current state, or brings a new instance back from any backup.
- **Scheduled backups**: a daily, dated, uncompressed tar to a folder or an S3-compatible bucket, laid out so a deduplicating tool such as restic stores only what changed. Restoring is in Settings, or `python -m grimoire.restore`. See [Backups](backups.md).
Installable as a desktop or mobile app; a service worker caches the shell.
## Sign-in
## Authentication
Native email + password, plus optional **OIDC** sign-in (Authentik, Keycloak, etc.)
that links to a matching local account. Invite links, a registration toggle, password
reset, and session invalidation are included. See [SSO / OAuth](sso-oauth.md).
Email and password, plus optional OIDC single sign-on (Authentik, Keycloak, ...) that links to a matching local account. Invite links, password reset and signing out other sessions are included. See [SSO / OAuth](sso-oauth.md).
## Settings
| Tab | Contents |
|-----|----------|
| General | Instance preferences (key/value) |
| Account | Email change, password change, session invalidation |
| Profile | Per-user profile fields |
| Integrations | SearXNG status |
| Data | Personal export; backup / restore (admin) |
| API Keys | Create/revoke `fmcp_` keys for the MCP endpoint |
| Config (admin) | Base URL, SMTP, DB-maintenance schedule |
| Users (admin) | User list, invite links, registration toggle |
| Logs (admin) | Error, audit, and usage logs with search |
| Groups (admin) | Create/manage groups and membership |
| General | Timezone, trash retention, retrieval thresholds, moments, reply shapes |
| Account | Email, password, active sessions |
| Notifications | Email notifications |
| Integrations | SearXNG, git forge connections |
| Data | Export, backup and restore, scheduled backups (admin) |
| MCP Access | API keys and the command to connect Claude |
| Config (admin) | What's running, reverse proxy count (checked against your own request), application URL, plugin marketplace, database maintenance, SMTP, forge webhook |
| Shared areas (admin) | The canonical areas systems map to |
| Platforms (admin) | The platform catalogue family canon is grouped by |
| Users (admin) | Registration, invites, users |
| Logs (admin) | Error, audit and usage logs |
| Groups (admin) | Groups and membership |
## Keyboard Shortcuts
## Keyboard shortcuts
| Key | Action |
|-----|--------|
| `g` + `h` | Home (dashboard) |
| `g` + `n` | Notes |
| `g` + `t` | Knowledge (tasks) |
| `g` + `p` | Projects |
| `g` + `r` | Rulebooks |
| `g` (bare) | Graph |
| `g` + `x` | Trash |
| `g` `h` | Dashboard |
| `g` `n` | Notes |
| `g` `t` | Knowledge (everything, tasks included) |
| `g` `p` | Projects |
| `g` `r` | Rules |
| `g` `g` | Graph |
| `g` `x` | Trash |
| `n` | New note |
| `t` | New task |
| `e` | Edit the note you're viewing |
| `/` | Focus search |
| `?` | Show shortcuts panel |
| `j` / `k` | Navigate list items |
| `Enter` | Open selected item |
| `e` | Edit current item |
| `Esc` | Close panel / blur / go home (progressive) |
| `Ctrl+S` | Save in editor |
| `?` | Show shortcuts |
| `j` / `k` | Move through a list |
| `Enter` | Open the selected item |
| `Esc` | Close the overlay, leave the field, then go to the dashboard |
| `Ctrl+S` | Save in the editor |
+5 -5
View File
@@ -1,6 +1,6 @@
# OAuth / OIDC SSO Setup
Fabled Assistant supports single sign-on via any OpenID Connect provider.
Grimoire supports single sign-on via any OpenID Connect provider.
This guide covers Authentik, but the same pattern works with Keycloak, Authelia, Zitadel, etc.
## 1. Create the provider in Authentik
@@ -8,7 +8,7 @@ This guide covers Authentik, but the same pattern works with Keycloak, Authelia,
1. Log in to the Authentik admin UI.
2. Go to **Applications → Providers → Create → OAuth2/OpenID Provider**.
3. Fill in:
- **Name:** `Fabled Assistant` (or whatever you like)
- **Name:** `Grimoire` (or whatever you like)
- **Authorization flow:** your default authorization flow
- **Client type:** `Confidential`
- **Redirect URIs:** `https://your-fabled-domain/api/auth/oauth/callback`
@@ -20,7 +20,7 @@ This guide covers Authentik, but the same pattern works with Keycloak, Authelia,
https://auth.example.com/application/o/fabled-assistant/
```
## 2. Configure Fabled Assistant
## 2. Configure Grimoire
Add the following environment variables to the `app` service in `docker-compose.yml`:
@@ -64,7 +64,7 @@ docker compose up --build -d
## 4. Account Linking
When a user logs in via OAuth for the first time, Fabled checks in this order:
When a user logs in via OAuth for the first time, Grimoire checks in this order:
1. **Existing OAuth sub** — returns that user immediately.
2. **Matching email** — if a local account already exists with the same email, the OAuth identity is linked to it automatically. The user retains all their notes and tasks.
@@ -92,4 +92,4 @@ The backend will reject any `POST /api/auth/login` or `POST /api/auth/register`
| Zitadel | `https://your-instance.zitadel.cloud` |
| Google | `https://accounts.google.com` |
The OIDC discovery endpoint (`<issuer>/.well-known/openid-configuration`) must be publicly reachable from the Fabled container (server-to-server call at login time).
The OIDC discovery endpoint (`<issuer>/.well-known/openid-configuration`) must be publicly reachable from the Grimoire container (server-to-server call at login time).
+4 -3
View File
@@ -4,13 +4,14 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#6c63ff">
<meta name="theme-color" content="#14171A">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Scribe">
<title>Fabled Scribe</title>
<meta name="apple-mobile-web-app-title" content="Grimoire">
<title>Fabled Grimoire</title>
</head>
<body>
<div id="app"></div>
+3 -2
View File
@@ -1,12 +1,13 @@
{
"name": "scribe-frontend",
"name": "grimoire-frontend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "scribe-frontend",
"name": "grimoire-frontend",
"version": "0.1.0",
"license": "AGPL-3.0-only",
"dependencies": {
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "scribe-frontend",
"name": "grimoire-frontend",
"private": true,
"license": "AGPL-3.0-only",
"version": "0.1.0",
"type": "module",
"scripts": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+16 -31
View File
@@ -1,33 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<style>
.book { fill: #6366f1; stroke: #4f46e5; }
.spine { stroke: #4f46e5; }
.lines { stroke: #a5b4fc; }
@media (prefers-color-scheme: dark) {
.book { fill: #e2e8f0; stroke: #cbd5e0; }
.spine { stroke: #cbd5e0; }
.lines { stroke: #4a5568; }
}
</style>
<!-- Book body -->
<path class="book" d="M4 7 C4 7 8 5 16 6 C24 5 28 7 28 7 L28 26 C28 26 24 24 16 25 C8 24 4 26 4 26 Z" stroke-width="0.5"/>
<!-- Book spine -->
<line class="spine" x1="16" y1="6" x2="16" y2="25" stroke-width="0.8"/>
<!-- Left page lines -->
<line class="lines" x1="7" y1="11" x2="14" y2="10.5" stroke-width="0.6" stroke-linecap="round"/>
<line class="lines" x1="7" y1="14" x2="14" y2="13.5" stroke-width="0.6" stroke-linecap="round"/>
<line class="lines" x1="7" y1="17" x2="14" y2="16.5" stroke-width="0.6" stroke-linecap="round"/>
<line class="lines" x1="7" y1="20" x2="12" y2="19.5" stroke-width="0.6" stroke-linecap="round"/>
<!-- Right page lines -->
<line class="lines" x1="18" y1="10.5" x2="25" y2="11" stroke-width="0.6" stroke-linecap="round"/>
<line class="lines" x1="18" y1="13.5" x2="25" y2="14" stroke-width="0.6" stroke-linecap="round"/>
<line class="lines" x1="18" y1="16.5" x2="25" y2="17" stroke-width="0.6" stroke-linecap="round"/>
<!-- Magic spark -->
<g transform="translate(24, 5)">
<path d="M0 -4 L1 -1 L4 0 L1 1 L0 4 L-1 1 L-4 0 L-1 -1 Z" fill="#f6ad55"/>
<path d="M0 -2.5 L0.6 -0.6 L2.5 0 L0.6 0.6 L0 2.5 L-0.6 0.6 L-2.5 0 L-0.6 -0.6 Z" fill="#fbd38d"/>
</g>
<!-- Secondary sparkles -->
<circle cx="20" cy="3" r="0.7" fill="#f6ad55" opacity="0.7"/>
<circle cx="27" cy="8" r="0.5" fill="#fbd38d" opacity="0.6"/>
<!-- page block, seen at the fore-edge -->
<path d="M8.5 4.5 H25.5 Q27 4.5 27 6 V27 Q27 28.5 25.5 28.5 H8.5 Z" fill="#E9DFC6"/>
<path d="M25 7 V26.5" stroke="#C9BB97" stroke-width="0.6"/>
<!-- cover -->
<path d="M5 4 Q5 3 6 3 H24 Q25.5 3 25.5 4.5 V26.5 Q25.5 28 24 28 H6 Q5 28 5 27 Z" fill="#5B4A8A"/>
<!-- spine band -->
<rect x="5" y="3" width="3.2" height="25" rx="0.6" fill="#47396E"/>
<!-- corner fittings -->
<path d="M21.5 3 H24 Q25.5 3 25.5 4.5 V7 Z" fill="#D4A94E"/>
<path d="M21.5 28 H24 Q25.5 28 25.5 26.5 V24 Z" fill="#D4A94E"/>
<!-- clasp across the fore-edge -->
<rect x="22.5" y="14" width="5.5" height="3.6" rx="0.8" fill="#D4A94E"/>
<circle cx="26.4" cy="15.8" r="0.7" fill="#8A6A22"/>
<!-- sigil: a four-pointed star in a ring -->
<circle cx="15.5" cy="15.5" r="5.2" fill="none" stroke="#D4A94E" stroke-width="0.9"/>
<path d="M15.5 11.4 L16.5 14.5 L19.6 15.5 L16.5 16.5 L15.5 19.6 L14.5 16.5 L11.4 15.5 L14.5 14.5 Z" fill="#F1D48A"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+17 -7
View File
@@ -1,17 +1,27 @@
{
"name": "Fabled Scribe",
"short_name": "Scribe",
"description": "Your self-hosted second brain with AI assistance",
"name": "Fabled Grimoire",
"short_name": "Grimoire",
"description": "A self-hosted work system-of-record for software projects, built to be driven by Claude Code.",
"start_url": "/",
"display": "standalone",
"background_color": "#1a1a2e",
"theme_color": "#6c63ff",
"background_color": "#14171A",
"theme_color": "#14171A",
"orientation": "portrait-primary",
"icons": [
{
"src": "/favicon.ico",
"src": "/favicon.svg",
"sizes": "any",
"type": "image/x-icon"
"type": "image/svg+xml"
},
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+19 -8
View File
@@ -13,11 +13,10 @@ import { fetchVersion } from "@/api/version";
useTheme();
const router = useRouter();
// THREE states, not two (#3127 checklist 12). `null` is "not answered yet" and
// renders nothing; a string renders; `appVersionFailed` renders its own thing.
// This used to default to the literal "dev" and swallow the error, which meant
// an instance that could not answer was indistinguishable from a local build
// that genuinely reports "dev" — a blank standing in for `unknown`, in the one
// THREE states, not two. `null` is "not answered yet" and renders nothing; a
// string renders; `appVersionFailed` renders its own thing. Defaulting to the
// literal "dev" and swallowing the error would make an instance that cannot
// answer indistinguishable from a local build that genuinely reports "dev" — a blank standing in for `unknown`, in the one
// readout whose whole job is to say what is running.
const appVersion = ref<string | null>(null);
const appVersionFailed = ref(false);
@@ -85,7 +84,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
switch (e.key) {
case "h": router.push("/"); break;
case "n": router.push("/notes"); break;
case "t": router.push("/"); break;
case "t": router.push("/knowledge"); break;
case "p": router.push("/projects"); break;
case "r": router.push("/rules"); break;
case "g": router.push("/graph"); break;
@@ -182,7 +181,7 @@ onUnmounted(() => {
<kbd class="shortcut-key">g</kbd>
<span class="shortcut-key-sep">+</span>
<kbd class="shortcut-key">h</kbd>
<span class="shortcut-desc">Home</span>
<span class="shortcut-desc">Dashboard</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">g</kbd>
@@ -202,6 +201,18 @@ onUnmounted(() => {
<kbd class="shortcut-key">p</kbd>
<span class="shortcut-desc">Projects</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">g</kbd>
<span class="shortcut-key-sep">+</span>
<kbd class="shortcut-key">r</kbd>
<span class="shortcut-desc">Rules</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">g</kbd>
<span class="shortcut-key-sep">+</span>
<kbd class="shortcut-key">g</kbd>
<span class="shortcut-desc">Graph</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">g</kbd>
<span class="shortcut-key-sep">+</span>
@@ -210,7 +221,7 @@ onUnmounted(() => {
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">Esc</kbd>
<span class="shortcut-desc">Unfocus field → go home</span>
<span class="shortcut-desc">Unfocus field → go to the dashboard</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">?</kbd>
+142
View File
@@ -0,0 +1,142 @@
import { apiGet, apiPost, apiPostRaw, apiPut } from "@/api/client";
/**
* Scheduled backups — `/api/admin/backups`. One dated,
* uncompressed tar per run, delivered to a folder or an S3 bucket.
*/
export interface BackupConfig {
enabled: boolean;
/** UTC hour the daily backup runs at, 0–23. */
hour: number;
destination: "folder" | "s3";
folder_path: string;
s3_endpoint: string;
s3_bucket: string;
s3_access_key: string;
s3_region: string;
s3_verify_tls: boolean;
}
export interface BackupRun {
started_at: string;
finished_at: string;
trigger: "scheduled" | "manual";
status: "ok" | "failed";
destination?: string;
file?: string;
size_bytes?: number;
where?: string;
error?: string;
duration_s: number;
}
export interface BackupsOverview {
config: BackupConfig;
/** Never the secret itself: whether one is kept, and whether this
* instance can still open it. */
secret: { set: boolean; readable: boolean };
/** What stops the saved config from sending a backup, in words. */
problems: string[];
running: boolean;
/** Newest first, at most 30. */
runs: BackupRun[];
last_run: BackupRun | null;
last_success: BackupRun | null;
overdue: boolean;
}
/** What the form sends: config fields, plus the secret when one was typed. */
export type BackupChanges = Partial<BackupConfig> & {
s3_secret?: string;
clear_s3_secret?: boolean;
};
export function getBackups(): Promise<BackupsOverview> {
return apiGet<BackupsOverview>("/api/admin/backups");
}
export function saveBackups(changes: BackupChanges): Promise<BackupsOverview> {
return apiPut<BackupsOverview>("/api/admin/backups", changes);
}
/** Ask the destination whether it would take a backup, with the form's
* unsaved values. The server gives the destination 45s; this waits a little
* longer so its own answer, not the client's deadline, is what arrives. */
export function checkBackupDestination(
changes: BackupChanges,
): Promise<{ ok: boolean; message: string }> {
return apiPost<{ ok: boolean; message: string }>("/api/admin/backups/check", changes, { timeoutMs: 60_000 });
}
export function runBackupNow(): Promise<{ status: string }> {
return apiPost<{ status: string }>("/api/admin/backups/run", {});
}
// ── Restore: put the whole instance back to a full backup ─────────────────
export interface StoredBackup {
name: string;
size_bytes: number;
}
export interface RestoreCount {
kind: string;
now: number;
after: number;
}
export interface RestorePreview {
/** "fresh": an empty instance takes the backup. "rollback": this instance
* goes back to its own earlier backup. */
mode: "fresh" | "rollback";
exported_at: string | null;
counts: RestoreCount[];
}
export interface RestoreResult extends RestorePreview {
status: "ok";
restored: Record<string, number>;
safety_backup: { file: string; size_bytes: number; where: string; note?: string };
}
/** A backup at the destination, by name, or a file picked from this computer. */
export type RestoreFrom = { source: string } | { file: File };
// Reading, checking and writing the whole store is slow by design; bounded,
// but in minutes.
const RESTORE_TIMEOUT_MS = 10 * 60 * 1000;
export function listRestoreSources(): Promise<{ backups: StoredBackup[]; error?: string }> {
return apiGet("/api/admin/restore/sources", { timeoutMs: 60_000 });
}
async function restoreCall<T>(path: string, from: RestoreFrom, query: Record<string, string> = {}): Promise<T> {
const params = new URLSearchParams(query);
if ("source" in from) {
params.set("source", from.source);
return apiPostRaw<T>(`${path}?${params}`, null, null, { timeoutMs: RESTORE_TIMEOUT_MS });
}
const qs = params.toString() ? `?${params}` : "";
const isTar = from.file.name.toLowerCase().endsWith(".tar");
// The JSON download is parsed first, so a damaged file fails here, before
// the upload.
const body = isTar ? from.file : JSON.stringify(JSON.parse(await from.file.text()));
return apiPostRaw<T>(`${path}${qs}`, body, isTar ? "application/x-tar" : "application/json",
{ timeoutMs: RESTORE_TIMEOUT_MS });
}
export function previewRestore(from: RestoreFrom): Promise<RestorePreview> {
return restoreCall<RestorePreview>("/api/admin/restore/preview", from);
}
export function restoreBackup(from: RestoreFrom): Promise<RestoreResult> {
return restoreCall<RestoreResult>("/api/admin/restore", from, { confirm: "rollback" });
}
/** When a backup named `grimoire-20261015T020000Z.tar` was taken. */
export function backupTakenAt(name: string): Date | null {
const m = /(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/.exec(name);
if (!m) return null;
return new Date(Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]));
}
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Canonical systems — the GLOBAL area vocabulary every project's Systems can
* map onto (milestone 307).
* map onto.
*
* The mapping is an ASSOCIATION, never a rename: a project's System keeps the
* name the project gave it, and `canonical_id` only records which shared area
+15 -25
View File
@@ -41,8 +41,8 @@ async function handleResponse<T>(res: Response, path: string): 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.
* the error envelope is unpacked on the client, so no view restates it as a
* `"body" in e` branch at its catch site.
*/
export function apiErrorMessage(e: unknown, fallback: string): string {
if (e && typeof e === "object" && "body" in e) {
@@ -55,21 +55,20 @@ export function apiErrorMessage(e: unknown, fallback: string): string {
/**
* How long an ordinary JSON call may wait before it is declared failed.
*
* Rule 156: a wait with no deadline is a bug. `fetch`'s own default is to wait
* A wait with no deadline is a bug. `fetch`'s own default is to wait
* as long as the browser will, which is not a deadline — it is the absence of
* one, and it renders as a spinner that never resolves. There is no state a
* surface can show for "pending forever" that is not a lie.
*
* 30s is chosen to be longer than anything healthy: it has to clear a cold
* embedding call and a list view under connection-pool contention (#2384 had
* /api/projects fanning 25 concurrent sessions at a 15-connection pool), so
* embedding call and a list view under connection-pool contention, so
* tripping it means something is genuinely wrong rather than merely busy. Slow
* BY DESIGN is a different case and passes its own value — see the callers in
* SettingsView that do.
*/
const DEFAULT_TIMEOUT_MS = 30_000;
/** HTTP 408. Not a status any Scribe route returns, so it unambiguously means
/** HTTP 408. Not a status any Grimoire route returns, so it unambiguously means
* "the client gave up" rather than anything the server said. */
const CLIENT_TIMEOUT_STATUS = 408;
@@ -118,7 +117,7 @@ export interface RequestOpts {
* The one place a request is actually made — every verb below goes through
* here, so the deadline cannot be forgotten by adding a sixth.
*
* EXPIRY SURFACES AS AN `ApiError`, which is rule 156's second half: the
* EXPIRY SURFACES AS AN `ApiError`, the deadline's second half: the
* failure has to arrive in the shape the caller already handles. A bare
* `DOMException: TimeoutError` would reach `apiErrorMessage(e, fallback)` as
* an object with no `body`, so every catch site in the app would report its
@@ -157,6 +156,15 @@ export function apiPost<T>(path: string, body: unknown, opts?: RequestOpts): Pro
return request<T>(path, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
}
/** A POST whose body is sent as it is (a file, or text already serialised),
* under the same deadline and error handling as every other call. */
export function apiPostRaw<T>(
path: string, body: BodyInit | null, contentType: string | null, opts?: RequestOpts,
): Promise<T> {
const headers = contentType ? { "Content-Type": contentType } : undefined;
return request<T>(path, { method: "POST", headers, body }, opts);
}
export function apiPut<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
return request<T>(path, { method: "PUT", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
}
@@ -501,24 +509,6 @@ export const createApiKey = (name: string, scope: 'read' | 'write') =>
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
// ── User Profile ─────────────────────────────────────────────────────────────
export interface UserProfile {
display_name: string
job_title: string
industry: string
expertise_level: 'novice' | 'intermediate' | 'expert'
response_style: 'concise' | 'balanced' | 'detailed'
tone: 'casual' | 'professional' | 'technical'
interests: string[]
work_schedule: { days?: string[]; start?: string; end?: string }
}
export const getProfile = () => apiGet<UserProfile>('/api/profile')
export const updateProfile = (data: Partial<UserProfile>) =>
apiPut<UserProfile>('/api/profile', data)
// ── Note Versions (pinning) ──────────────────────────────────────────────────
import type { NoteVersion } from '../types/task'
+3 -3
View File
@@ -1,5 +1,5 @@
/**
* Design systems — the stylesheet held as records (milestone #254).
* Design systems — the stylesheet held as records.
*
* A design system is a named set of tokens with an optional parent. A system
* with no parent is a "family"; one with a parent holds ONLY what it changes,
@@ -82,8 +82,8 @@ export interface StarterRoleGroup {
}
/** The starter token ROLES offered at creation — names and purposes, never
* values. A default palette would be one install's taste shipped as product
* (rule #115), so the values are always the operator's to fill. */
* values. A default palette would be one install's taste shipped as product,
* so the values are always the operator's to fill. */
export const listStarterRoleGroups = () =>
apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>(
"/api/design-systems/starter-roles",

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