Compare commits

..
Author SHA1 Message Date
Renovate Bot 5bf55fc488 Add renovate.json 2026-08-04 04:01:43 +00:00
635 changed files with 12631 additions and 112647 deletions
+16 -102
View File
@@ -46,6 +46,8 @@ on:
- "alembic/**"
- "alembic.ini"
- "Dockerfile"
- "assets/**"
- "fable-mcp/**"
# 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
@@ -111,7 +113,7 @@ jobs:
# 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).
# can't check yet (shellcheck and jq are absent from ci-python).
plugin:
name: Plugin hooks
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
@@ -133,15 +135,12 @@ jobs:
# 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.
# jq is not optional for the smoke test: every hook exits at line 1
# without it, so the check would pass while exercising nothing.
- name: Install shell tooling
run: |
apt-get update -qq
apt-get install -y -qq --no-install-recommends shellcheck
apt-get install -y -qq --no-install-recommends jq shellcheck
# On main the comparison would be against itself, so only the syntax and
# pattern checks mean anything there.
@@ -179,15 +178,6 @@ jobs:
- 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.
- name: Dangling style check
run: python3 scripts/check_dangling_styles.py
test:
name: Python tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
@@ -226,12 +216,6 @@ jobs:
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.
- 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"
@@ -276,44 +260,18 @@ jobs:
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)
run: |
echo "--- rule 81: 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? ---"
getent hosts postgres \
|| echo "no — 'postgres' does not resolve; the bridge-IP lookup is still required"
- name: Integration suite (resolve service IP, migrate, test)
run: |
set -eux
echo "=== container landscape (diagnostic for the name filter) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
# Only THIS job's services. The runner's docker daemon is shared, so another
# repo's job can be running beside this one, and a job-name filter matches its
# containers too (Steward run 8358 and Inkwell run 8653 each took another repo's
# Postgres). act names every container of a task 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=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg17" -q | head -n1)
test -n "$PG"
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.
# Wait for Postgres to accept connections (busybox sh — the runner
# default — has no bash /dev/tcp, so use Python).
/opt/venv/bin/python - "$PG_IP" <<'PY'
import socket, sys, time
for _ in range(30):
@@ -338,13 +296,7 @@ jobs:
# 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.
#
# `integration` IS in needs (rule 177, #4958). It was added after this
# gate and left out of it, so on 2026-10-05 main run 8212 published
# :latest with integration red. The image carries the migrations and the
# queries that lane runs against real Postgres; a red integration run is
# a red image.
needs: [typecheck, lint, test, integration]
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>.
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
@@ -356,14 +308,6 @@ jobs:
packages: write
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.
fetch-depth: 0
- name: Generate image tags and version
id: tags
@@ -376,27 +320,7 @@ jobs:
# the runner log on commit 2a374d9.
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.
# 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).
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.
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.
CHANNEL="dev"
BUILD_VERSION="dev"
case "${{ github.ref }}" in
refs/heads/dev)
TAGS="$TAGS,${{ env.IMAGE }}:dev"
@@ -405,17 +329,15 @@ jobs:
# main IS the production line: publish :latest (plus the :<sha>
# set above). No separate :main tag.
TAGS="$TAGS,${{ env.IMAGE }}:latest"
CHANNEL="stable"
BUILD_VERSION="main"
;;
refs/tags/*)
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
CHANNEL="stable"
BUILD_VERSION="${{ github.ref_name }}"
;;
esac
echo "value=$TAGS" >> $GITHUB_OUTPUT
echo "build_name=$BUILD_NAME" >> $GITHUB_OUTPUT
echo "build_key=$BUILD_KEY" >> $GITHUB_OUTPUT
echo "channel=$CHANNEL" >> $GITHUB_OUTPUT
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
- name: Free disk space
# Self-hosted runner housekeeping. Two-step cleanup:
@@ -445,15 +367,7 @@ 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.
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 }}
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
# 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
+2 -21
View File
@@ -41,29 +41,10 @@ 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.
# Version is injected at build time via --build-arg BUILD_VERSION=YY.MM.DD.N
# Falls back to "dev" for local / untagged builds
ARG BUILD_VERSION=dev
ARG BUILD_KEY=
ARG BUILD_CHANNEL=
ARG BUILD_COMMIT=
ENV APP_VERSION=$BUILD_VERSION
ENV APP_BUILD_KEY=$BUILD_KEY
ENV APP_CHANNEL=$BUILD_CHANNEL
ENV APP_COMMIT=$BUILD_COMMIT
EXPOSE 5000
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
+1 -10
View File
@@ -1,4 +1,4 @@
.PHONY: build up down logs health migrate lint typecheck test fmt mint-plugin
.PHONY: build up down logs health migrate lint typecheck test fmt
# --- Docker ---
@@ -36,12 +36,3 @@ test:
# Run all checks in one shot (mirrors what CI does)
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.
mint-plugin:
python3 scripts/mint_plugin_version.py
+1 -1
View File
@@ -4,7 +4,7 @@ A self-hosted work system-of-record for software projects, built to be driven by
## Features
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system (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.
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system, and semantic search with proactive knowledge-injection into Claude's context. A knowledge graph, per-user/group sharing, and a built-in MCP server (`/mcp`) plus a bundled Claude Code plugin so Claude can record and recall your work directly.
## Quick Start
@@ -1,47 +0,0 @@
"""retire the two settings that designated a design source for the app itself
Revision ID: 0075
Revises: 0074
Create Date: 2026-08-03
Two keys, retired for the same reason a week apart, so they go in one change
rather than one migration each:
design_rulebook_id which rulebook described how this app should look
ui_design_system_id which design system this app's own UI was built from
Both named a design source for THE RUNNING INSTALL. The design surface is for
the projects an install tracks, and a project already carries its own pointer
(`projects.design_system_id`) — so an install-wide designation had nothing left
to mean. `ui_design_system_id` was introduced by this same migration's first
draft and never reached a deployed database; it is listed here rather than
undone by an 0076 that would reverse a change nobody ran.
Deleting settings rows by key is safe in a way dropping a column is not — the
table is free-form key/value, so an install that never designated one simply has
no row to delete.
Downgrade cannot restore what it never recorded, so it is a no-op rather than a
lie: the pointer lives on the project now, and always did for anyone who set it
there.
"""
from alembic import op
import sqlalchemy as sa
revision = "0075"
down_revision = "0074"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
sa.text(
"DELETE FROM settings "
"WHERE key IN ('design_rulebook_id', 'ui_design_system_id')"
)
)
def downgrade() -> None:
pass
-115
View File
@@ -1,115 +0,0 @@
"""note_supersessions; drop the never-written notes.consolidated_at
Revision ID: 0076
Revises: 0075
Create Date: 2026-08-07
Step 1 of milestone #278. Structure only — nothing reads or writes the new
table yet, and nothing behaves differently after this runs.
## What the table is for
Old records outrank newer ones on the same subject, because a similarity score
cannot tell time. A note that accurately described how something worked in June
is still accurate ABOUT June; it is just no longer the answer. Nothing recorded
that, so nothing could act on it.
The claim points FORWARD — the newer record names what it overtakes — because
the older one cannot know it has been overtaken. Many-to-many and partial: a
note may supersede parts of several others and be overtaken piecemeal by
several later ones, which is why this is a table rather than a column. Both
directions are queried: `superseded_id` answers "has this been overtaken?" at
ranking time, `superseder_id` answers "what does this replace?" in a record
view. An array column could serve one and not the other.
CASCADE on both sides is safe because trashing is not a delete: `trash_svc`
stamps `deleted_at`, so a trashed note keeps its claims and `restore` brings
them back. The cascade fires only on `purge_trash`, where the row genuinely
goes — and a claim about a row that no longer exists is not actionable.
## What is being dropped, and why now
`notes.consolidated_at` was written by NOTHING — no service, no route, no tool
— while being serialised into every note and task payload as `null`. It cost a
column, a line in every response, and worse: it IMPLIED a capability. A reader
reasonably concludes notes can be consolidated and this records when.
That reading was reasonable precisely because merge/unmerge exists for snippets
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
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
older one is merely ranked behind. Smuggling one in under a column named for
the other would have buried that difference in schema.
## Downgrade
Re-adds `consolidated_at` nullable, which is how it lived — so downgrade
restores the shape, not the (nonexistent) data. Drops the table; any recorded
supersession claims are lost, which costs ranking its input and nothing else,
since no note's own content depends on them.
"""
import sqlalchemy as sa
from alembic import op
revision = "0076"
down_revision = "0075"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"note_supersessions",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column(
"superseder_id",
sa.Integer,
sa.ForeignKey("notes.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"superseded_id",
sa.Integer,
sa.ForeignKey("notes.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.UniqueConstraint(
"superseder_id", "superseded_id", name="uq_note_supersessions_pair"
),
# Declaring that a note supersedes ITSELF is meaningless, and under flat
# demotion it would demote a record on its own authority. Refused in the
# service too, with a message — this is the backstop that holds when
# something writes rows directly.
sa.CheckConstraint(
"superseder_id <> superseded_id", name="ck_note_supersessions_not_self"
),
)
op.create_index(
"ix_note_supersessions_superseder", "note_supersessions", ["superseder_id"]
)
op.create_index(
"ix_note_supersessions_superseded", "note_supersessions", ["superseded_id"]
)
op.drop_column("notes", "consolidated_at")
def downgrade() -> None:
op.add_column(
"notes",
sa.Column("consolidated_at", sa.DateTime(timezone=True), nullable=True),
)
op.drop_index("ix_note_supersessions_superseded", table_name="note_supersessions")
op.drop_index("ix_note_supersessions_superseder", table_name="note_supersessions")
op.drop_table("note_supersessions")
@@ -1,54 +0,0 @@
"""Chunked embeddings: one note_embeddings row per chunk (#280)
Revision ID: 0077
Revises: 0076
Create Date: 2026-08-09
The embedding model reads at most 512 tokens and fastembed truncates the rest
silently, so the old one-row-per-note shape permanently lost everything past
~400 words of a record. A note now stores one row per chunk of
`embeddings.chunk_document`: PK (note_id, chunk_index), plus the chunk's text
(inspectability + future "matched section" surfacing) and the chunker version
that produced it (so later shape changes re-embed by version comparison
instead of repeating this wipe).
Embeddings are DERIVED data (0067 precedent): rows are cleared here and the
startup backfill regenerates the whole corpus at the new shape on next boot.
The HNSW index is untouched — it indexes chunk rows exactly as it indexed
note rows.
"""
from alembic import op
revision = "0077"
down_revision = "0076"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Derived data — the version-aware startup backfill re-embeds everything
# at the chunked shape. Old whole-document rows would be indistinguishable
# from properly-chunked single-chunk notes, so they cannot be carried over.
op.execute("DELETE FROM note_embeddings")
# Empty table, so NOT NULL columns need no defaults and the PK swap is
# instant.
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunk_index integer NOT NULL")
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunk_text text NOT NULL")
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunker_version integer NOT NULL")
op.execute("ALTER TABLE note_embeddings DROP CONSTRAINT note_embeddings_pkey")
op.execute(
"ALTER TABLE note_embeddings ADD PRIMARY KEY (note_id, chunk_index)"
)
def downgrade() -> None:
# Same reasoning in reverse: chunk rows make no sense to a whole-document
# reader, so clear and let the old backfill regenerate.
op.execute("DELETE FROM note_embeddings")
op.execute("ALTER TABLE note_embeddings DROP CONSTRAINT note_embeddings_pkey")
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunk_index")
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunk_text")
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunker_version")
op.execute("ALTER TABLE note_embeddings ADD PRIMARY KEY (note_id)")
-130
View File
@@ -1,130 +0,0 @@
"""Forge connections move to the user level (#2778)
Revision ID: 0078
Revises: 0077
Create Date: 2026-08-19
A forge token is a user's credential, not an instance's: the single
admin-settings config meant every user's snippet-freshness and coverage reads
ran under the operator's token. Each user now owns a keyring of connections —
one per forge host — and projects resolve forge reads on their OWNER's
keyring, with an optional per-project pin (projects.forge_connection_id).
The data move carries the existing admin config into a connection row for the
first admin user (host parsed from the base URL), then deletes the old
setting keys outright — no legacy dual-read (rule #22). The env-var channel
(FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) is untouched by this migration; it
survives as an implicit keyring entry for admin users only.
"""
from urllib.parse import urlsplit
import sqlalchemy as sa
from alembic import op
revision = "0078"
down_revision = "0077"
branch_labels = None
depends_on = None
_SETTING_KEYS = ("forge_kind", "forge_base_url", "forge_token")
def upgrade() -> None:
op.create_table(
"forge_connections",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("base_url", sa.Text(), nullable=False),
sa.Column("host", sa.Text(), nullable=False),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("user_id", "host", name="uq_forge_connections_user_host"),
)
op.add_column(
"projects",
sa.Column(
"forge_connection_id",
sa.BigInteger(),
sa.ForeignKey(
"forge_connections.id",
ondelete="SET NULL",
name="fk_projects_forge_connection_id",
),
nullable=True,
),
)
# Data move: the admin-settings config becomes the first admin's keyring
# row. All three values must be present — a partial config never produced
# an adapter, so carrying it over would invent a connection that never
# worked.
conn = op.get_bind()
row = conn.execute(
sa.text(
"SELECT s.key, s.value FROM settings s"
" JOIN users u ON u.id = s.user_id"
" WHERE u.role = 'admin' AND s.key IN :keys"
" AND s.user_id = ("
" SELECT MIN(id) FROM users WHERE role = 'admin'"
" )"
).bindparams(sa.bindparam("keys", expanding=True)),
{"keys": list(_SETTING_KEYS)},
).fetchall()
values = {key: (value or "").strip() for key, value in row}
kind = values.get("forge_kind", "").lower()
base_url = values.get("forge_base_url", "").rstrip("/")
token = values.get("forge_token", "")
host = (urlsplit(base_url).hostname or "").lower()
if kind and base_url and token and host:
conn.execute(
sa.text(
"INSERT INTO forge_connections"
" (user_id, kind, base_url, host, token, created_at, updated_at)"
" SELECT MIN(id), :kind, :base_url, :host, :token, NOW(), NOW()"
" FROM users WHERE role = 'admin'"
),
{"kind": kind, "base_url": base_url, "host": host, "token": token},
)
conn.execute(
sa.text(
"DELETE FROM settings WHERE key IN :keys"
).bindparams(sa.bindparam("keys", expanding=True)),
{"keys": list(_SETTING_KEYS)},
)
def downgrade() -> None:
# Reverse data move: the first admin's row (if any) becomes the admin
# settings again. Other users' rows have no pre-0078 representation and
# are dropped with the table.
conn = op.get_bind()
row = conn.execute(
sa.text(
"SELECT user_id, kind, base_url, token FROM forge_connections"
" WHERE user_id = (SELECT MIN(id) FROM users WHERE role = 'admin')"
" ORDER BY id LIMIT 1"
)
).fetchone()
if row is not None:
for key, value in (
("forge_kind", row.kind),
("forge_base_url", row.base_url),
("forge_token", row.token),
):
conn.execute(
sa.text(
"INSERT INTO settings (user_id, key, value)"
" VALUES (:uid, :key, :value)"
" ON CONFLICT (user_id, key) DO UPDATE SET value = :value"
),
{"uid": row.user_id, "key": key, "value": value},
)
op.drop_column("projects", "forge_connection_id")
op.drop_table("forge_connections")
@@ -1,66 +0,0 @@
"""The shape ledger: code_shapes (#2787, milestone 294)
Revision ID: 0079
Revises: 0078
Create Date: 2026-08-19
The accounting half of the pattern system (governing note 2786): the snippet
library records canon (small); this table accounts for EVERY shape the
coverage extractor finds in a bound repo (total). Rows arrive `unclassified`
from the coverage sync (step 2) and gain judgments — canonical / instance /
variant / exempt — from audits, hooks, and the mechanical proposer.
Unclassified IS the todo list.
"""
import sqlalchemy as sa
from alembic import op
revision = "0079"
down_revision = "0078"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"code_shapes",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"project_id",
sa.Integer(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("repo_key", sa.Text(), nullable=False),
sa.Column("path", sa.Text(), nullable=False),
sa.Column("symbol", sa.Text(), nullable=False),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False, server_default="unclassified"),
sa.Column(
"snippet_id",
sa.BigInteger(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("classified_by", sa.Text(), nullable=True),
sa.Column("classified_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("first_seen_commit", sa.Text(), nullable=False, server_default=""),
sa.Column("last_seen_commit", sa.Text(), nullable=False, server_default=""),
sa.Column("vanished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint(
"project_id", "repo_key", "path", "symbol", "kind",
name="uq_code_shapes_identity",
),
)
op.create_index(
"ix_code_shapes_project_status", "code_shapes", ["project_id", "status"]
)
op.create_index("ix_code_shapes_snippet", "code_shapes", ["snippet_id"])
def downgrade() -> None:
op.drop_index("ix_code_shapes_snippet", table_name="code_shapes")
op.drop_index("ix_code_shapes_project_status", table_name="code_shapes")
op.drop_table("code_shapes")
@@ -1,54 +0,0 @@
"""Shape fingerprints + the mechanical proposer's columns (#2792, milestone 294)
Revision ID: 0080
Revises: 0079
Create Date: 2026-08-21
Two additions to the ledger. `signature` / `body_sha` fingerprint each shape
(definition line + a whitespace/comment-insensitive hash of its block) so the
proposer can match on content and a later drift recheck can notice change,
without the ledger ever storing code. The proposal columns carry the
proposer's standing suggestion for an unclassified row — instance-of-#N with
a basis and score, or a derive-first group key — and `proposed_sha`
remembers the content it was judged at so a refresh re-examines only what
changed. Mechanical and recomputable: a restore that lacks them loses
nothing the next refresh does not rebuild.
"""
import sqlalchemy as sa
from alembic import op
revision = "0080"
down_revision = "0079"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("code_shapes", sa.Column("signature", sa.Text(), nullable=False, server_default=""))
op.add_column("code_shapes", sa.Column("body_sha", sa.Text(), nullable=False, server_default=""))
op.add_column(
"code_shapes",
sa.Column(
"proposed_snippet_id",
sa.BigInteger(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
)
op.add_column("code_shapes", sa.Column("proposal_basis", sa.Text(), nullable=True))
op.add_column("code_shapes", sa.Column("proposal_score", sa.Float(), nullable=True))
op.add_column("code_shapes", sa.Column("proposal_group", sa.Text(), nullable=True))
op.add_column("code_shapes", sa.Column("proposed_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("code_shapes", sa.Column("proposed_sha", sa.Text(), nullable=False, server_default=""))
op.create_index(
"ix_code_shapes_proposed", "code_shapes", ["project_id", "proposed_snippet_id"]
)
def downgrade() -> None:
op.drop_index("ix_code_shapes_proposed", table_name="code_shapes")
for col in (
"proposed_sha", "proposed_at", "proposal_group", "proposal_score",
"proposal_basis", "proposed_snippet_id", "body_sha", "signature",
):
op.drop_column("code_shapes", col)
@@ -1,70 +0,0 @@
"""Shape history, recheck, and the divergence flag (#2793, milestone 294)
Revision ID: 0081
Revises: 0080
Create Date: 2026-08-21
The payoff surface of the ledger. `classified_sha` remembers the fingerprint
a judgment was made at so a later body change under an instance/variant can
flag `recheck_at`; `diverges_from` is the button-B flag (a shape new since
the previous refresh, where one canon dominates its directory+kind, and not
proposed as that canon). `code_shape_events` is the what-was-used-when
record: every classification, vanish, reappearance, and drift as it
happened — history the row alone cannot keep once it moves on.
"""
import sqlalchemy as sa
from alembic import op
revision = "0081"
down_revision = "0080"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("code_shapes", sa.Column("classified_sha", sa.Text(), nullable=False, server_default=""))
op.add_column("code_shapes", sa.Column("recheck_at", sa.DateTime(timezone=True), nullable=True))
op.add_column(
"code_shapes",
sa.Column(
"diverges_from",
sa.BigInteger(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index("ix_code_shapes_diverges", "code_shapes", ["project_id", "diverges_from"])
op.create_table(
"code_shape_events",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"shape_id",
sa.Integer(),
sa.ForeignKey("code_shapes.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("project_id", sa.Integer(), nullable=False),
sa.Column("path", sa.Text(), nullable=False),
sa.Column("symbol", sa.Text(), nullable=False),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("event", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=True),
sa.Column("snippet_id", sa.BigInteger(), nullable=True),
sa.Column("classified_by", sa.Text(), nullable=True),
sa.Column("reason", sa.Text(), nullable=True),
sa.Column("commit", sa.Text(), nullable=False, server_default=""),
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_code_shape_events_shape", "code_shape_events", ["shape_id", "at"])
op.create_index(
"ix_code_shape_events_project_path", "code_shape_events", ["project_id", "path"]
)
def downgrade() -> None:
op.drop_index("ix_code_shape_events_project_path", table_name="code_shape_events")
op.drop_index("ix_code_shape_events_shape", table_name="code_shape_events")
op.drop_table("code_shape_events")
op.drop_index("ix_code_shapes_diverges", table_name="code_shapes")
for col in ("diverges_from", "recheck_at", "classified_sha"):
op.drop_column("code_shapes", col)
-26
View File
@@ -1,26 +0,0 @@
"""Per-binding ref — the branch a project's ledger follows (#2873, milestone 294)
Revision ID: 0082
Revises: 0081
Create Date: 2026-08-21
A repo binding used to imply the repo's default branch; the shape ledger
therefore only saw work after a merge to main, while the operator's work
lands on dev (rule 1). `ref` names the branch the coverage refresh reads —
NULL keeps today's behaviour (the forge's default branch).
"""
import sqlalchemy as sa
from alembic import op
revision = "0082"
down_revision = "0081"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("repo_bindings", sa.Column("ref", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("repo_bindings", "ref")
@@ -1,26 +0,0 @@
"""Exempt/variant reason codes — a small fixed catalogue beside the prose (#2874, milestone 294)
Revision ID: 0083
Revises: 0082
Create Date: 2026-08-21
The 2026-08 audit wrote the same free-text reason thousands of times
("scoped rule — styles one element of this view"); a judgment's WHY stays
prose, but an optional code from a fixed catalogue makes the ledger
filterable and aggregable ("how many pure helpers, how many test helpers").
"""
import sqlalchemy as sa
from alembic import op
revision = "0083"
down_revision = "0082"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("code_shapes", sa.Column("reason_code", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("code_shapes", "reason_code")
-40
View File
@@ -1,40 +0,0 @@
"""code_shape_uses — consumption edges, separate from conformance (#2870, milestone 294)
Revision ID: 0084
Revises: 0083
Create Date: 2026-08-21
A ledger row carries ONE snippet_id: what shape this is (instance/variant of
a canon). But a shape can also CALL several canonical helpers — e.g. a
service function both conforming to the service-function convention and
consuming hash_token. The 2026-08 audit had to pick one; hook evidence
("pulled #N then wrote code referencing it") was stamped as instance when it
is a uses fact. This table holds the many-valued relation: shape → snippet,
with the basis and the evidence. Cascades with the shape and the snippet.
"""
import sqlalchemy as sa
from alembic import op
revision = "0084"
down_revision = "0083"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"code_shape_uses",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("shape_id", sa.Integer(), sa.ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False),
sa.Column("snippet_id", sa.Integer(), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
sa.Column("basis", sa.Text(), nullable=False),
sa.Column("evidence", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.UniqueConstraint("shape_id", "snippet_id", name="uq_code_shape_uses_shape_snippet"),
)
op.create_index("ix_code_shape_uses_snippet", "code_shape_uses", ["snippet_id"])
def downgrade() -> None:
op.drop_index("ix_code_shape_uses_snippet", table_name="code_shape_uses")
op.drop_table("code_shape_uses")
@@ -1,74 +0,0 @@
"""Project inception: the decision record + always-on rulebook exclusions (milestone 297)
Revision ID: 0085
Revises: 0084
Create Date: 2026-08-22
`projects.inception` is the WHY a project inherits what it does — NULL until
someone decides, at which point enter_project stops asking. The new
association `project_rulebook_exclusions` is the opt-out of a whole always-on
rulebook for one project (the sibling of the rule/topic suppressions).
Backfill: every project that exists when this runs is stamped
via="legacy" with its CURRENT standing (no exclusions, its subscriptions,
its design_system_id, no seed) — so the ask fires only for projects created
after the step shipped, and nothing a running install relies on changes.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0085"
down_revision = "0084"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"projects",
sa.Column("inception", postgresql.JSONB(), nullable=True),
)
op.create_table(
"project_rulebook_exclusions",
sa.Column(
"project_id", sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
primary_key=True, nullable=False,
),
sa.Column(
"rulebook_id", sa.BigInteger(),
sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
primary_key=True, nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=False,
),
)
# Legacy stamp: what each existing project inherits today, recorded as a
# decision so the inception ask does not fire on a project that has been
# running for months.
op.execute(sa.text("""
UPDATE projects p SET inception = jsonb_build_object(
'via', 'legacy',
'decided_at', to_jsonb(now()),
'decided_by', NULL,
'choices', jsonb_build_object(
'exclude_always_on_rulebooks', '[]'::jsonb,
'subscribe_rulebooks', COALESCE(
(SELECT jsonb_agg(s.rulebook_id ORDER BY s.rulebook_id)
FROM project_rulebook_subscriptions s
WHERE s.project_id = p.id),
'[]'::jsonb),
'design_system_id', to_jsonb(p.design_system_id),
'seed_systems', false
)
)
WHERE p.inception IS NULL
"""))
def downgrade() -> None:
op.drop_table("project_rulebook_exclusions")
op.drop_column("projects", "inception")
@@ -1,35 +0,0 @@
"""code_shape_consumers — the CSS consumer map (milestone 302, note 2917)
Revision ID: 0086
Revises: 0085
Create Date: 2026-08-23
CSS is watched by name, by recipe, by token and by WHAT USES IT. This table
holds the fourth: CSS shape → the file whose markup names its class, with how
many times. Mechanical and recomputed by every coverage sync from the repo
archive; the analogue of code_shape_uses for styling. Cascades with the shape.
"""
import sqlalchemy as sa
from alembic import op
revision = "0086"
down_revision = "0085"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"code_shape_consumers",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("shape_id", sa.Integer(), sa.ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False),
sa.Column("path", sa.Text(), nullable=False),
sa.Column("count", sa.Integer(), nullable=False, server_default="1"),
sa.Column("basis", sa.Text(), nullable=False, server_default="template"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.UniqueConstraint("shape_id", "path", name="uq_code_shape_consumers_shape_path"),
)
def downgrade() -> None:
op.drop_table("code_shape_consumers")
-109
View File
@@ -1,109 +0,0 @@
"""canonical_systems — the global area vocabulary, promoted from a constant
to a table (milestone 307 step 1, decision note 3026)
Revision ID: 0087
Revises: 0086
Create Date: 2026-08-26
The eight standard area names already existed as `STANDARD_SYSTEMS`, a tuple in
services/systems.py that milestone 297 seeds into a project at inception. A
constant cannot be referenced: a rule that applies across projects has nothing
to point at, because `systems.project_id` is NOT NULL and a family rule cannot
be chained to one project's row. This makes the vocabulary a table so it can be
a foreign key, and adds the nullable `systems.canonical_id` that maps a
project's local System onto it.
Deliberately no `user_id`: the catalog is GLOBAL so a shared project inherits
the vocabulary rather than re-earning it. `record_systems` is untouched — it
joins note_id/system_id and never sees this table, so no association data
moves, and no System's own `name` is rewritten.
The seed rows are written here verbatim rather than imported from the service:
a migration is a historical record and must keep running unchanged after the
service's list moves on.
"""
import sqlalchemy as sa
from alembic import op
revision = "0087"
down_revision = "0086"
branch_labels = None
depends_on = None
# (name, slug, description) — the milestone-297 vocabulary, with the slug the
# service computes (canonical_slug: lowercase, "&" -> "and", non-alphanumerics
# collapsed to "-"). Charters stay generic on purpose: a project refines its
# own System's description, never this one. Nothing here names an app, a repo,
# a vendor or a house convention — the catalog ships to every install (rule 115).
_SEED = (
("CI & Release", "ci-and-release",
"How the project is verified and shipped: pipelines, runners, image/artifact builds, release tagging and rollback."),
("Auth & Access", "auth-and-access",
"Who may do what: identity, sessions/tokens, permissions and the scoping of every read and write to the right users."),
("Data Model & Storage", "data-model-and-storage",
"What is stored and how it is shaped: the schema, migrations, serialisation and the services that own a table's lifecycle."),
("API Surface", "api-surface",
"The doors into the capability: HTTP routes, tool/RPC surfaces, request parsing, error envelopes and their contracts."),
("UI & Design", "ui-and-design",
"What people see and touch: views, components, client state, and the design tokens/recipes they are built from."),
("Import & Export", "import-and-export",
"Data crossing the boundary: backups, exports, imports, sync with other systems, file formats."),
("Background Jobs", "background-jobs",
"Work that runs without a request: schedulers, queues, periodic ticks, retention and maintenance."),
("Observability", "observability",
"How the system reports on itself: logging, metrics, audit trails, health and diagnostics."),
)
def upgrade() -> None:
canonical_systems = op.create_table(
"canonical_systems",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("slug", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
)
# Unique among LIVE rows only, so a soft-deleted entry doesn't block
# recreating or restoring the same area (the rules/topics convention).
op.create_index(
"uq_canonical_systems_slug", "canonical_systems", ["slug"],
unique=True, postgresql_where=sa.text("deleted_at IS NULL"),
)
op.bulk_insert(
canonical_systems,
[
{"name": name, "slug": slug, "description": description, "order_index": index}
for index, (name, slug, description) in enumerate(_SEED)
],
)
op.add_column(
"systems",
sa.Column("canonical_id", sa.Integer(), nullable=True),
)
# SET NULL, not CASCADE: retiring a catalog entry must never delete a
# project's System along with it.
op.create_foreign_key(
"fk_systems_canonical_id", "systems", "canonical_systems",
["canonical_id"], ["id"], ondelete="SET NULL",
)
op.create_index("ix_systems_canonical_id", "systems", ["canonical_id"])
# Existing Systems are left UNMAPPED on purpose. An exact-slug match would
# be safe, but a near miss ("CI & runners" vs "CI & Release") is a judgment
# call — those go through the propose/confirm path so a human approves each
# one, rather than being decided by a migration nobody reviews.
def downgrade() -> None:
op.drop_index("ix_systems_canonical_id", table_name="systems")
op.drop_constraint("fk_systems_canonical_id", "systems", type_="foreignkey")
op.drop_column("systems", "canonical_id")
op.drop_index("uq_canonical_systems_slug", table_name="canonical_systems")
op.drop_table("canonical_systems")
@@ -1,105 +0,0 @@
"""rules gain a trigger, a tier, canon tags and typed edges (milestone 307
step 3, decision note 3026)
Revision ID: 0088
Revises: 0087
Create Date: 2026-08-26
A rule could not say WHEN it applies, WHICH area it is about, or WHAT other
rule it belongs with. All three were being written as prose instead — a
project's System description restating rule text, a rule's `why` naming the
note that caused it, and two halves of one shape merged into a single row
because either could surface without the other.
Four additions, each replacing something that was already being said in words:
- `when_to_apply` — the trigger. Nullable HERE and required at the service
layer, because existing rules have none and a migration cannot invent one.
- `tier` — `always_on` (preloaded, as everything is today) or `conditional`
(reachable, surfaced when its trigger fires). Defaults to `always_on`, so
this migration changes NOTHING about which rules bind: an install upgrades
and every rule keeps arriving exactly as it did.
- `arose_from_id` — the record that caused the rule, the edge notes and tasks
already have.
- `rule_systems` / `rule_relations` — the canon tag and the typed edges.
"""
import sqlalchemy as sa
from alembic import op
revision = "0088"
down_revision = "0087"
branch_labels = None
depends_on = None
# Kept in one place so upgrade and the CHECK agree by construction (rule 36:
# a whitelisted value means DROP + ADD CONSTRAINT in the same migration —
# there is no prior constraint here, so the pair is created together).
_TIERS = ("always_on", "conditional")
_RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
def _in_list(column: str, values: tuple[str, ...]) -> str:
return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")"
def upgrade() -> None:
op.add_column("rules", sa.Column("when_to_apply", sa.Text(), nullable=True))
op.add_column(
"rules",
sa.Column("tier", sa.Text(), nullable=False, server_default="always_on"),
)
op.create_check_constraint("ck_rules_tier", "rules", _in_list("tier", _TIERS))
# SET NULL, not CASCADE: the record that prompted a rule can be trashed
# without taking the rule with it — provenance is a claim about history,
# and losing the source does not repeal the rule.
op.add_column("rules", sa.Column("arose_from_id", sa.BigInteger(), nullable=True))
op.create_foreign_key(
"fk_rules_arose_from_id", "rules", "notes",
["arose_from_id"], ["id"], ondelete="SET NULL",
)
# Which global AREA a rule is about. Points at the canonical catalog, never
# at a project's `systems` row — a rule that spans projects cannot be
# chained to one project's vocabulary (0087).
op.create_table(
"rule_systems",
sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
sa.Column("canonical_id", sa.Integer(), sa.ForeignKey("canonical_systems.id", ondelete="CASCADE"), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
op.create_index("ix_rule_systems_canonical_id", "rule_systems", ["canonical_id"])
# Typed edges between rules. Each kind exists because its absence forced a
# workaround: co_surfaces (merging two rules into one row), overrides (a
# stricter project rule written as a duplicate), elaborates (a local
# addendum sitting beside its parent with nothing to say it is one).
op.create_table(
"rule_relations",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column("from_rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False),
sa.Column("to_rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False),
sa.Column("kind", sa.Text(), nullable=False),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.CheckConstraint(_in_list("kind", _RELATION_KINDS), name="ck_rule_relations_kind"),
# A rule cannot relate to itself, and one pair carries a given kind
# once — a second row would surface the same rule twice.
sa.CheckConstraint("from_rule_id <> to_rule_id", name="ck_rule_relations_not_self"),
sa.UniqueConstraint("from_rule_id", "to_rule_id", "kind", name="uq_rule_relations_edge"),
)
op.create_index("ix_rule_relations_from", "rule_relations", ["from_rule_id"])
op.create_index("ix_rule_relations_to", "rule_relations", ["to_rule_id"])
def downgrade() -> None:
op.drop_index("ix_rule_relations_to", table_name="rule_relations")
op.drop_index("ix_rule_relations_from", table_name="rule_relations")
op.drop_table("rule_relations")
op.drop_index("ix_rule_systems_canonical_id", table_name="rule_systems")
op.drop_table("rule_systems")
op.drop_constraint("fk_rules_arose_from_id", "rules", type_="foreignkey")
op.drop_column("rules", "arose_from_id")
op.drop_constraint("ck_rules_tier", "rules", type_="check")
op.drop_column("rules", "tier")
op.drop_column("rules", "when_to_apply")
-58
View File
@@ -1,58 +0,0 @@
"""rule_embeddings — rules become findable by meaning (milestone 307 step 4,
decision note 3026)
Revision ID: 0089
Revises: 0088
Create Date: 2026-08-26
Rules were the only major record type with no vector, so `search` could never
return one and a rule could only ever arrive by being preloaded. That single
fact is what made every rule compete for the same always-on budget.
A sibling table rather than a generalisation of note_embeddings: the row could
have been made polymorphic, but the SEARCH could not — semantic_search_notes is
Note-specific scoping end to end, and a rule shares none of it. See the model
docstring for the full reasoning.
The vectors are DERIVED data. Nothing is backfilled here: the startup backfill
regenerates them, which is also how a chunker-version bump is handled.
"""
import sqlalchemy as sa
from alembic import op
revision = "0089"
down_revision = "0088"
branch_labels = None
depends_on = None
# Matches note_embeddings — bge-small-en-v1.5, 384-dim unit-normalized.
_EMBEDDING_DIM = 384
def upgrade() -> None:
op.create_table(
"rule_embeddings",
sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
sa.Column("chunk_index", sa.Integer(), primary_key=True),
sa.Column("chunk_text", sa.Text(), nullable=False),
sa.Column("chunker_version", sa.Integer(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
# The vector column is added by raw DDL for the same reason 0067 did it:
# the type comes from the pgvector extension, not from SQLAlchemy's
# type system.
op.execute(f"ALTER TABLE rule_embeddings ADD COLUMN embedding vector({_EMBEDDING_DIM}) NOT NULL")
# HNSW for cosine distance — matches Vector.cosine_distance (`<=>`), so the
# search is an indexed ORDER BY ... LIMIT k rather than a full scan.
op.execute(
"""
CREATE INDEX ix_rule_embeddings_embedding_hnsw
ON rule_embeddings
USING hnsw (embedding vector_cosine_ops)
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_rule_embeddings_embedding_hnsw")
op.drop_table("rule_embeddings")
@@ -1,64 +0,0 @@
"""a rule can carry its own check — verify_with, expires_when, verified_at
(milestone 312 step 1)
Revision ID: 0090
Revises: 0089
Create Date: 2026-08-27
A rulebook holds two kinds of row in one table. A NORM is a decision: it has
no truth value, and it changes only when its author changes it — which they
know they did. A CONSTRAINT is a fact about someone else's software: a
runner's shell, a bot's config, a tool that exists. Nobody is present when
that goes false.
Milestone 307's rulebook audit found nine stale sites. Every one was a
constraint; not one norm had rotted. One of them had been telling every
session to skip database-backed tests for weeks while the integration lane
sat green in the workflow.
Three nullable columns, so a rule can say how to check itself:
- `verify_with` — how to tell whether this is still true. A command, a path,
a URL, a query. Prose is allowed; something runnable is better.
- `expires_when` — the STATE under which it stops being true. Deliberately
not a date: constraints do not expire on a schedule, they expire when the
world underneath them moves.
- `verified_at` — when the check last passed. NULL means never checked, and
sorts FIRST in the sweep: unexamined outranks examined-long-ago.
All three nullable and all three optional, because most rules should set
none of them. A null `verify_with` is not an omission — it is the honest
marker of "this one is a decision, and there is nothing to go and check."
That signal only works if the field stays empty wherever it belongs empty.
No CHECK constraint is involved, so rule 36 does not apply here. Nothing is
backfilled: a migration cannot invent a check any more than 0088 could
invent a trigger.
"""
import sqlalchemy as sa
from alembic import op
revision = "0090"
down_revision = "0089"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("rules", sa.Column("verify_with", sa.Text(), nullable=True))
op.add_column("rules", sa.Column("expires_when", sa.Text(), nullable=True))
op.add_column(
"rules",
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
)
# No index on (verify_with, verified_at). The sweep this exists for reads
# an operator's whole rulebook — hundreds of rows, not millions — and runs
# when a human asks for it, never on a request path. An index here would
# be maintained on every rule write to serve a query that a sequential
# scan answers instantly.
def downgrade() -> None:
op.drop_column("rules", "verified_at")
op.drop_column("rules", "expires_when")
op.drop_column("rules", "verify_with")
-66
View File
@@ -1,66 +0,0 @@
"""task_kind gains 'spike' — the investigation, not the change
(milestone 312 step 5)
Revision ID: 0091
Revises: 0090
Create Date: 2026-08-27
A spike is a task shape the others cannot hold. `work` ships a change;
`issue` fixes something broken. A spike is time-boxed and its output is
KNOWLEDGE — it succeeds by producing an answer, and nothing ships at the
end of it. "Find out whether the runner can be given a bash shell" is not
work, and filing it as work makes a finished investigation look like an
abandoned change.
It is the record a failed check asks for. Milestone 312 gave rules a
`verify_with`; when one of those fails, the rule is wrong and the next move
is often to go and find out what replaced it. `notes.arose_from_id` already
exists (0065), so that constraint -> spike link needs no further schema.
Rule 36: `task_kind` is gated by a CHECK whitelist, so the value and the
widened constraint land in the SAME migration — DROP then ADD, exactly as
0065 did when it introduced 'issue'. Adding the value and constraining it
later leaves a window where the database accepts anything.
'plan' stays in the list though it is retired (plans are milestones since
0066): historical plan-tasks still carry it, and dropping it from the
whitelist would make old rows unwritable.
"""
from alembic import op
revision = "0091"
down_revision = "0090"
branch_labels = None
depends_on = None
# One tuple so the upgrade and the downgrade cannot disagree about what the
# list was on either side of this migration.
_KINDS_AFTER = ("work", "plan", "issue", "spike")
_KINDS_BEFORE = ("work", "plan", "issue")
# Restated rather than imported from 0088, which has the same helper. A
# migration is a snapshot: it must keep working when the code around it has
# moved on, so it never imports from live modules or from its siblings. Six
# duplicated lines are the price of that, and the cheap half of the bargain.
def _in_list(values: tuple[str, ...]) -> str:
return "task_kind IN (" + ", ".join(f"'{v}'" for v in values) + ")"
def upgrade() -> None:
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
op.create_check_constraint(
"notes_task_kind_check", "notes", _in_list(_KINDS_AFTER),
)
def downgrade() -> None:
# Any row already filed as a spike would violate the narrowed constraint,
# so they are demoted to 'work' first. Lossy and deliberately so: the
# alternative is a downgrade that fails on real data, which is worse than
# a downgrade that says what it did.
op.execute("UPDATE notes SET task_kind = 'work' WHERE task_kind = 'spike'")
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
op.create_check_constraint(
"notes_task_kind_check", "notes", _in_list(_KINDS_BEFORE),
)
@@ -1,80 +0,0 @@
"""a note can carry its own check — verify_with, expires_when, verified_at
(milestone 317 step 1)
Revision ID: 0092
Revises: 0091
Create Date: 2026-08-28
The sibling of 0090, which gave rules the same three columns. Same
distinction, one table over:
A NORM is a decision — no truth value, and it changes only when its author
changes it, which they know they did. A CONSTRAINT is a fact about someone
else's software, and nobody is present when it goes false.
Notes hold far more constraints than rules do, and hold them for longer. A
cross-project reference note asserting what a signing service does on a
duplicate upload, or how a forge numbers its CI runs, is believed by every
project that reads it, and there is nothing in the record that says when
anyone last looked. `note_supersessions` only fires once a human has read
the note, disagreed, and written the correction — which is the case where
the note was already believed.
Three nullable columns:
- `verify_with` — how to tell whether this is still true. A command, a path,
a URL, a query. Prose is allowed; something runnable is better.
- `expires_when` — the STATE under which it stops being true. Deliberately
not a date: constraints do not expire on a schedule, they expire when the
world underneath them moves.
- `verified_at` — when the check last passed. NULL means never checked, and
sorts FIRST in the sweep: unexamined outranks examined-long-ago.
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
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
to express something the write path can say in two lines.
All three optional, because most notes should set none of them — the whole
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
backfilled: a migration cannot invent a check.
"""
import sqlalchemy as sa
from alembic import op
revision = "0092"
down_revision = "0091"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("notes", sa.Column("verify_with", sa.Text(), nullable=True))
op.add_column("notes", sa.Column("expires_when", sa.Text(), nullable=True))
op.add_column(
"notes",
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
)
# No index, for 0090's reason — the sweep runs when a human asks, never on
# a request path — but the margin is thinner here and worth naming. `rules`
# is hundreds of rows; `notes` is thousands and grows with every session.
#
# 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.
def downgrade() -> None:
op.drop_column("notes", "verified_at")
op.drop_column("notes", "expires_when")
op.drop_column("notes", "verify_with")
-83
View File
@@ -1,83 +0,0 @@
"""rules gain an edit history — rule_versions (milestone 323 step 1)
Revision ID: 0093
Revises: 0092
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.
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:
- **No pruning, and no MAX_VERSIONS.** That cap defends against note autosave
filling every slot. Rules have no autosave; every edit is a deliberate
update_rule. A rule is edited a handful of times in its life, and capping
invites losing the one edit somebody needed.
- **No pin columns.** `pin_kind`/`pin_label` exist so a note's version can
survive that pruning. With nothing pruning, a pin protects a row that was
never at risk.
- **No minimum interval.** 300 seconds between snapshots is also an autosave
defence; here it would only ever discard a second deliberate edit.
`user_id` is the ACTOR rather than the owner, and is SET NULL rather than
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
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.
"""
import sqlalchemy as sa
from alembic import op
revision = "0093"
down_revision = "0092"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"rule_versions",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column(
"rule_id",
sa.BigInteger(),
sa.ForeignKey("rules.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.BigInteger(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("title", sa.Text(), nullable=False, server_default=""),
sa.Column("statement", sa.Text(), nullable=False, server_default=""),
sa.Column("why", sa.Text(), nullable=True),
sa.Column("how_to_apply", sa.Text(), nullable=True),
sa.Column("when_to_apply", sa.Text(), nullable=True),
sa.Column("tier", sa.Text(), nullable=True),
sa.Column("verify_with", sa.Text(), nullable=True),
sa.Column("expires_when", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
# The only query this table serves is "the history of THIS rule, newest
# first" — unlike 0092's columns, which are read by an operator-initiated
# sweep over the whole set. Every read here is keyed on rule_id, so the
# index earns its write cost immediately rather than on a hunch.
op.create_index("ix_rule_versions_rule_id", "rule_versions", ["rule_id"])
def downgrade() -> None:
op.drop_index("ix_rule_versions_rule_id", table_name="rule_versions")
op.drop_table("rule_versions")
@@ -1,86 +0,0 @@
"""add rule_usage_events — was a surfaced rule ever read? (milestone 333 step 1)
Revision ID: 0094
Revises: 0093
Create Date: 2026-09-02
The sibling `note_usage_events` has had since 0071, and the third rule-side
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`
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
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 —
telemetry not merely lost but wrong, and wrong in a way nothing downstream
could detect. `rule_versions` made the same call for the same reason.
FK-free on `rule_id` and `user_id`, matching note_usage_events, retrieval_logs
and app_logs — and deliberately unlike `rule_versions`, which does carry FKs.
The difference is what the row is for: a version belongs to a rule's history
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,
and a two-member enum whose members are written by two functions in one module
is not where that discipline earns its cost.
Downgrade drops the table outright. The data is purely observational — nothing
reads it for correctness, so losing it costs history and no behaviour.
"""
from alembic import op
import sqlalchemy as sa
revision = "0094"
down_revision = "0093"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"rule_usage_events",
# BigInteger throughout where the note twin uses Integer: rules.id is
# BigInteger, so rule_id must be, and a high-churn append-only table is
# a poor place to discover an id ceiling.
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("rule_id", sa.BigInteger(), nullable=False),
sa.Column("event", sa.Text(), nullable=False),
sa.Column("source", sa.Text(), nullable=False),
)
# Every readout is "these rule ids, split by event", so the composite is the
# one that actually gets used; the others serve pruning and per-user views.
op.create_index(
"ix_rule_usage_rule_event", "rule_usage_events", ["rule_id", "event"]
)
op.create_index("ix_rule_usage_created_at", "rule_usage_events", ["created_at"])
op.create_index("ix_rule_usage_user_id", "rule_usage_events", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_rule_usage_user_id", table_name="rule_usage_events")
op.drop_index("ix_rule_usage_created_at", table_name="rule_usage_events")
op.drop_index("ix_rule_usage_rule_event", table_name="rule_usage_events")
op.drop_table("rule_usage_events")
@@ -1,52 +0,0 @@
"""add retrieval_logs.suppressed_count — tell a ranker decline from a repeat (#3497)
Revision ID: 0095
Revises: 0094
Create Date: 2026-09-03
`result_count == 0` has always meant "this surface said nothing", which is the
right number for "was the hint any use" and the wrong one for tuning a
threshold. It folds together two unrelated events:
- the ranker found nothing above the bar — the ONLY evidence a threshold is
set too high; and
- the ranker found something the session had already been shown — a decline
that says nothing whatever about the bar.
The rule arms filter in Python after the search, so they can count the second
kind exactly. The note arms pass `exclude_ids` INTO semantic_search_notes, so
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.
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,
so no importer changes.
Downgrade drops the column. Purely observational — nothing reads it for
correctness.
"""
from alembic import op
import sqlalchemy as sa
revision = "0095"
down_revision = "0094"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"retrieval_logs",
sa.Column("suppressed_count", sa.Integer(), nullable=True),
)
def downgrade() -> None:
op.drop_column("retrieval_logs", "suppressed_count")
@@ -1,62 +0,0 @@
"""add retrieval_logs.best_available_score — the score the bar rejected (#3670)
Revision ID: 0096
Revises: 0095
Create Date: 2026-09-08
`cleared_threshold` was documented as the number to read FIRST — "a surface
that clears its bar on nearly every call is either well-tuned or too loose,
and p10 says which". It was never a measurement. The search applies the
threshold before returning, so every returned result cleared the bar by
construction and a call with no results has no `top_score` to compare:
the condition is true exactly when `result_count > 0`.
`zero_result_calls + cleared_threshold == calls` held on all nineteen
source/window readings ever taken. It was `calls - zero_result_calls`
wearing a name that promised a second opinion, and a reading procedure was
built on top of it that asked the reader to compare a number against itself.
THE MISSING NUMBER, and the reason this is a column rather than a deletion.
The question the table exists to answer is "is the bar in the right place",
and that question is only answerable from the calls that returned NOTHING:
how close did the best rejected candidate come? A bar at 0.72 turning away
a stream of 0.71s is set too high by a hair. A bar turning away 0.30s is
doing its job. Those two are indistinguishable today — both render as a
zero-result call — and no arrangement of the existing columns separates
them, because the losing score is discarded inside the search.
So the searches now rank without the bar and apply it in Python, which
costs nothing (the rows were already ordered by distance, and the qualifying
set is provably identical — above-threshold rows sort first), and the best
score seen becomes observable.
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.
`retrieval_logs` is not restored from backup, so no importer changes.
Downgrade drops the column. Purely observational — nothing reads it for
correctness.
"""
from alembic import op
import sqlalchemy as sa
revision = "0096"
down_revision = "0095"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"retrieval_logs",
sa.Column("best_available_score", sa.Float(), nullable=True),
)
def downgrade() -> None:
op.drop_column("retrieval_logs", "best_available_score")
@@ -1,60 +0,0 @@
"""add retrieval_logs.best_available_id — WHICH record the bar refused (#3807)
Revision ID: 0097
Revises: 0096
Create Date: 2026-09-09
0096 added `best_available_score` so a threshold could be judged from what it
rejected. It records how CLOSE the bar came to firing and not WHAT it turned
away, and that turns out to be the half a decision actually needs.
Live, `pre_tool_rule` shows a bar of ~0.72 with a near-miss p90 of 0.7071 —
about 117 declines a day sitting within 0.013 of firing. Dropping the bar to
0.707 would take that arm from 22 hits a day to roughly 139, a six-fold change
on a surface that runs before every Bash call. The percentile says the mass is
there. Nothing says whether it is worth showing.
AND THE TWO OBVIOUS INSTRUMENTS DO NOT ANSWER IT. Pull-through cannot: the
injected rule line already carries title and trigger, so a session can comply
without ever calling `get_rule`, and rule pull-through therefore understates
usefulness by construction. Reading the rejected records can — and `result_ids`
holds only what was RETURNED, so on a zero-result call it is empty and the
near-missed record has no name.
So: the id, beside the score, from the SAME ranked candidate. The two must
never be able to describe different records — a score paired with its
neighbour's id would be worse than no id at all, because it invites a reader to
judge the wrong record and conclude the bar is fine.
NULLABLE AND UNBACKFILLED, for the reason 0095 and 0096 both spell out: a row
written before this genuinely does not know, and inventing a value would put an
artifact where a measurement belongs. Null here means "not measured", never
"nothing was close".
NOT A FOREIGN KEY, deliberately. `retrieval_logs` spans record types — the
rules arms store rule ids, the note arms store note ids — and `source` is what
says which table an id belongs to, exactly as `result_ids` has always worked.
A constraint would have to point at one table and would be wrong for the other.
Downgrade drops the column. Purely observational — nothing reads it for
correctness.
"""
from alembic import op
import sqlalchemy as sa
revision = "0097"
down_revision = "0096"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"retrieval_logs",
sa.Column("best_available_id", sa.Integer(), nullable=True),
)
def downgrade() -> None:
op.drop_column("retrieval_logs", "best_available_id")
-90
View File
@@ -1,90 +0,0 @@
"""rules gain a `kind` — a preference is a rule that does not bind (#3849)
Revision ID: 0098
Revises: 0097
Create Date: 2026-09-10
A rule says what must be followed. There was no way to record the other
thing the operator kept writing rules for: **how they want work done**.
Several rules in a mature rulebook are not really rules — pace this kind of
debugging, hand off an action with its reason, end a finding with an offer.
Ignoring one of those does not break anything or cross a boundary; it costs
consistency. They were written as rules because a rule was the only record
that is global, keyed to a situation, and delivered when that situation
arrives.
So: `kind`. `rule` binds. `preference` describes how this person wants it
done, and — the part that makes it its own kind rather than a softer label —
**it is expected to change as the work teaches it.** The agent updates a
preference in the ordinary course of working, where a rule waits for its
author.
WHY A COLUMN AND NOT A TABLE. Everything a preference needs already exists on
`rules` and nowhere else: `when_to_apply` as a real column, a
trigger-dominated embedding document, ownership-scoped search that is
deliberately not filtered to one project, three retrieval arms with per-arm
telemetry, typed relations, and versioning. The two differ in exactly one
dimension — force — and one dimension is a field.
The drift machinery is the decisive part. `rule_versions` already snapshots
every write, and `rule_relations.overrides` already models "this supersedes
that for its scope". A separate table would have rebuilt both, and moving
existing rows into it would have changed their ids — silently invalidating
every record in the corpus that cites a rule by number.
DEFAULTS TO `rule`, so this migration changes NOTHING about what binds. An
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,
so the pair is created together — a value added to it LATER does DROP + ADD
in one migration).
`rule_versions` GETS THE COLUMN TOO, and that half is not bookkeeping.
`record_if_changed` decides whether an edit is worth a snapshot by comparing
the fields a version carries; a field a version does not carry is a field
whose change records NO HISTORY AT ALL. Without this, turning a rule into a
preference — the single most consequential edit either kind can undergo,
because it is the moment something stops binding — would leave the history
silent. Nullable and no CHECK there, matching `tier`: a version is a record
of what was, and a constraint on it would refuse to store a kind later
dropped from the live whitelist.
Downgrade drops all three. Nothing reads `kind` for correctness; a rule that
was a preference simply becomes a rule again, which is the safe direction.
"""
import sqlalchemy as sa
from alembic import op
revision = "0098"
down_revision = "0097"
branch_labels = None
depends_on = None
# Kept in one place so upgrade and the CHECK agree by construction — 0088's
# idiom, for the same reason.
_KINDS = ("rule", "preference")
def _in_list(column: str, values: tuple[str, ...]) -> str:
return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")"
def upgrade() -> None:
op.add_column(
"rules",
sa.Column("kind", sa.Text(), nullable=False, server_default="rule"),
)
op.create_check_constraint("ck_rules_kind", "rules", _in_list("kind", _KINDS))
# Nullable, no CHECK — see the module docstring. A version written before
# this migration genuinely does not know, and NULL there means "not
# recorded", never "was a rule".
op.add_column("rule_versions", sa.Column("kind", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("rule_versions", "kind")
op.drop_constraint("ck_rules_kind", "rules", type_="check")
op.drop_column("rules", "kind")
@@ -1,124 +0,0 @@
"""scrub credential-shaped spans out of retrieval_logs.query
Revision ID: 0099
Revises: 0098
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).
`services/retrieval_telemetry.scrub_secrets` closes that going forward — the
value never reaches the column. It cannot reach BACKWARDS, and this does: it
rewrites the rows already written.
REDACTED IN PLACE, NOT DELETED. The rest of the row — score, threshold,
result count, duration, the near-miss record id — is legitimate evidence, and
it is what a threshold is tuned from. Deleting the row would throw that away
to remove a secret that lives in one column, so the column is what gets
rewritten. Rows with no credential in them are not touched at all.
THE PATTERNS ARE INLINED RATHER THAN IMPORTED, deliberately, against the DRY
instinct. A migration is a frozen record of a change that already happened on
every install that ran it; importing the live patterns would mean this
migration quietly does something different next year than it did when it ran,
and two installs at the same revision would no longer be in the same state.
The Python twin in `services/retrieval_telemetry.py` is free to grow — this is
what ran here, once. The one thing that must not drift is coverage, and the
guard for that is `test_retrieval_query_scrubbing.py`, which tests the live
function rather than this copy.
POSIX regex, not Python's. Postgres ARE supports the non-greedy `*?` the PEM
pattern needs, and `\\s`/`\\S`, so the shapes port directly. The `'gi'` flags
are global + case-insensitive, matching `re.sub` with `(?i)`.
NO BARE `auth` IN THE ASSIGNED PATTERN. It matches `--author=`, so a commit
naming an address would have had the address redacted — evidence eaten for a
word that only looks credential-shaped. `AUTH_TOKEN` is still caught, by
`token`.
Downgrade is a no-op, and honestly so: the original text is gone and a
migration cannot invent it back. Saying that plainly is better than a
downgrade that appears to restore something and does not.
"""
from alembic import op
revision = "0099"
down_revision = "0098"
branch_labels = None
depends_on = None
# Vendor-prefixed credentials — the prefix IS the tell, so no entropy guessing.
#
# `\m` IS LOAD-BEARING AND IS NOT `\b`. It anchors the prefix to the START OF
# A WORD, which the Python twin spells `\b`. Two separate mistakes were made
# porting this and they compounded:
#
# 1. The boundary was dropped entirely, so `sk-` matched inside any word
# containing it. `<task-notification>` — a string that appears in
# thousands of these rows — became `<ta[redacted:token]>`, because
# `sk-` + `notification` is a prefix followed by twelve word characters.
# 2. Writing `\b` would not have fixed it. In Postgres ARE `\b` is a
# BACKSPACE character, not a word boundary; `\m` (start of word) and
# `\y` (either edge) are the spellings that mean what Python's `\b`
# means.
#
# Both were live for one run of this migration, on one install, and the cost
# is recorded rather than papered over: the mangled rows cannot be restored,
# because the original text is what the UPDATE overwrote. The live scrubber in
# services/retrieval_telemetry.py was never affected — its `\b` is Python's
# and behaves correctly, which is why rows written after the deploy are intact
# and only migration-rewritten ones were damaged.
_TOKEN = (
r"\m(fmcp_|flt_|ghp_|gho_|ghs_|ghu_|github_pat_|glpat-|xox[abprs]-"
r"|sk-[A-Za-z0-9]*-?|AKIA|ASIA)[A-Za-z0-9_\-]{12,}"
)
# A value handed to a secret-NAMED variable, in shell, env, YAML, JSON or a
# query string. The NAME identifies it, so the value can be anything.
_ASSIGNED = (
r"\m([A-Za-z0-9_]*(token|secret|password|passwd|api[_-]?key|access[_-]?key)"
r"[A-Za-z0-9_]*)(\s*[:=]\s*[\"']?)([^\s\"'&]{8,})"
)
_AUTH_HEADER = r"(authorization\s*:\s*(bearer|basic|token)\s+)(\S+)"
_PEM = (
r"-----BEGIN [A-Z ]*PRIVATE KEY-----(.|\n)*?-----END [A-Z ]*PRIVATE KEY-----"
)
def _lit(pattern: str) -> str:
"""A regex as a SQL string literal.
A single quote inside a single-quoted SQL literal has to be DOUBLED, and
the assigned-value pattern contains two of them (it allows an optional
quote around the value). Left unescaped they close the literal early and
the migration dies on a syntax error — which is the whole reason this
helper exists rather than the patterns being pasted in inline.
"""
return pattern.replace("'", "''")
_SCRUB_SQL = f"""
UPDATE retrieval_logs
SET query = regexp_replace(
regexp_replace(
regexp_replace(
regexp_replace(query, '{_lit(_TOKEN)}', '[redacted:token]', 'gi'),
'{_lit(_ASSIGNED)}', '\\1\\3[redacted:assigned]', 'gi'),
'{_lit(_AUTH_HEADER)}', '\\1[redacted:auth-header]', 'gi'),
'{_lit(_PEM)}', '[redacted:private-key]', 'gi')
WHERE query IS NOT NULL
AND (query ~* '{_lit(_TOKEN)}'
OR query ~* '{_lit(_ASSIGNED)}'
OR query ~* '{_lit(_AUTH_HEADER)}'
OR query ~* '{_lit(_PEM)}')
"""
def upgrade() -> None:
op.execute(_SCRUB_SQL)
def downgrade() -> None:
"""Deliberately empty — the original text no longer exists to restore."""
@@ -1,96 +0,0 @@
"""drop the always-on tier: rules.tier, rulebooks.always_on, the exclusions table
Revision ID: 0100
Revises: 0099
Create Date: 2026-09-11
Milestone 394. Every rule now 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
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.
- ``rule_versions.tier`` (migration 0098). A version records what a rule
SAID; with no tier on a rule there is nothing for a snapshot to carry.
- ``rulebooks.always_on`` (migration 0058). A rulebook reaches a project by
subscription now, and by nothing else.
- ``project_rulebook_exclusions`` (migration 0085). It recorded a project's
opt-out of an always-on rulebook. Opting out of something that no longer
binds you is not a state that can exist — declining a rulebook is
expressed by not subscribing to it.
IRREVERSIBLE, AND THE DOWNGRADE SAYS SO RATHER THAN PRETENDING
The downgrade recreates the columns and the table with their DEFAULTS. It
cannot restore WHICH rules were always-on, which rulebooks bound every project,
or which projects had opted out — that information is in what this drops.
That distinction is the one this repo keeps insisting on: a value invented to
fill a hole is not a measurement. So a downgraded database is structurally able
to run the old code and is NOT the database the old code was running against —
every rule comes back at the ``always_on`` default, which for the tier column
happens to mean "binding", the safe direction to be wrong in.
Anyone who needs the real prior state restores a backup taken before this ran.
"""
import sqlalchemy as sa
from alembic import op
revision = "0100"
down_revision = "0099"
branch_labels = None
depends_on = None
_TIERS = ("always_on", "conditional")
def _in_list(column: str, values: tuple[str, ...]) -> str:
return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")"
def upgrade() -> None:
op.drop_table("project_rulebook_exclusions")
op.drop_column("rulebooks", "always_on")
op.drop_column("rule_versions", "tier")
# The CHECK goes with the column it constrains; naming it here would be a
# second drop of the same object.
op.drop_column("rules", "tier")
def downgrade() -> None:
"""Structure only. See the module docstring — the values are gone."""
op.add_column(
"rules",
sa.Column("tier", sa.Text(), nullable=False, server_default="always_on"),
)
op.create_check_constraint("ck_rules_tier", "rules", _in_list("tier", _TIERS))
op.add_column("rule_versions", sa.Column("tier", sa.Text(), nullable=True))
op.add_column(
"rulebooks",
sa.Column(
"always_on", sa.Boolean(), nullable=False,
server_default=sa.text("false"),
),
)
op.create_table(
"project_rulebook_exclusions",
sa.Column(
"project_id", sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
primary_key=True, nullable=False,
),
sa.Column(
"rulebook_id", sa.BigInteger(),
sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
primary_key=True, nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=True,
),
)
@@ -1,82 +0,0 @@
"""drop rulebook subscriptions and per-project suppressions
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."
WHAT GOES
- ``project_rulebook_subscriptions`` (migration 0058).
- ``project_rule_suppressions`` and ``project_topic_suppressions``. They let a
project mute rules from a rulebook it subscribed to. With no subscription
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
record carrying them would be read back as a choice the product offers.
IRREVERSIBLE, AND THE DOWNGRADE SAYS SO
The downgrade recreates the three tables empty. Which projects subscribed to
which rulebooks, and what they muted, is in what this drops. Restore a backup
taken before this ran if the prior state matters.
"""
import sqlalchemy as sa
from alembic import op
revision = "0101"
down_revision = "0100"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_table("project_topic_suppressions")
op.drop_table("project_rule_suppressions")
op.drop_table("project_rulebook_subscriptions")
op.execute(
"""
UPDATE projects
SET inception = jsonb_set(
inception, '{choices}',
(inception->'choices') - 'subscribe_rulebooks' - 'exclude_always_on_rulebooks'
)
WHERE inception IS NOT NULL
AND jsonb_typeof(inception->'choices') = 'object'
"""
)
def _join_table(name: str, other: str, other_table: str) -> None:
op.create_table(
name,
sa.Column(
"project_id", sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
primary_key=True, nullable=False,
),
sa.Column(
other, sa.BigInteger(),
sa.ForeignKey(f"{other_table}.id", ondelete="CASCADE"),
primary_key=True, nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=True,
),
)
def downgrade() -> None:
"""Structure only. See the module docstring — the rows are gone."""
_join_table("project_rulebook_subscriptions", "rulebook_id", "rulebooks")
_join_table("project_rule_suppressions", "rule_id", "rules")
_join_table("project_topic_suppressions", "topic_id", "rulebook_topics")
@@ -1,59 +0,0 @@
"""milestone_embeddings — a plan becomes findable by meaning (milestone 415)
Revision ID: 0102
Revises: 0101
Create Date: 2026-09-15
`search` covered notes, tasks and rules, and a milestone — the record a plan
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
backfill writes them.
"""
import sqlalchemy as sa
from alembic import op
revision = "0102"
down_revision = "0101"
branch_labels = None
depends_on = None
# Matches note_embeddings and rule_embeddings — bge-small-en-v1.5, 384-dim.
_EMBEDDING_DIM = 384
def upgrade() -> None:
op.create_table(
"milestone_embeddings",
sa.Column(
"milestone_id", sa.Integer(),
sa.ForeignKey("milestones.id", ondelete="CASCADE"), primary_key=True,
),
sa.Column("chunk_index", sa.Integer(), primary_key=True),
sa.Column("chunk_text", sa.Text(), nullable=False),
sa.Column("chunker_version", sa.Integer(), nullable=False),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
)
# Raw DDL for the vector column, as 0067 and 0089 do: the type comes from
# the pgvector extension, not SQLAlchemy's type system.
op.execute(
f"ALTER TABLE milestone_embeddings ADD COLUMN embedding vector({_EMBEDDING_DIM}) NOT NULL"
)
op.execute(
"""
CREATE INDEX ix_milestone_embeddings_embedding_hnsw
ON milestone_embeddings
USING hnsw (embedding vector_cosine_ops)
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_milestone_embeddings_embedding_hnsw")
op.drop_table("milestone_embeddings")
@@ -1,75 +0,0 @@
"""retrieval_tuning_events — why a floor is where it is (#4102)
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."
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
part an operator needs: what changed, from what to what, who moved it, and on
what evidence. This table is that trail, and it is what makes the delegation
reviewable rather than merely automatic.
Nothing is backfilled. A surface with no rows here is sitting on its shipped
starting point, which is a true and useful thing for the history to say.
"""
import sqlalchemy as sa
from alembic import op
revision = "0103"
down_revision = "0102"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"retrieval_tuning_events",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
# FK-free, like retrieval_logs and app_logs: the record of why a number
# is where it is must outlive the account that moved it.
sa.Column("user_id", sa.Integer(), nullable=True),
# Also the surface's `retrieval_logs.source`, so a change can be read
# next to what the change did.
sa.Column("surface", sa.Text(), nullable=False),
sa.Column("dial", sa.Text(), nullable=False),
# Nullable: the first change to a surface has no stored predecessor. It
# moved off the shipped starting point, which is a different event from
# moving off a value somebody chose.
sa.Column("old_value", sa.Float(), nullable=True),
sa.Column("new_value", sa.Float(), nullable=False),
sa.Column(
"actor", sa.Text(), nullable=False, server_default=sa.text("'model'")
),
# Non-null here; non-BLANK is enforced at the service boundary, because
# a column that merely forbids NULL is satisfied by "" and a required
# field that accepts "" is a formality.
sa.Column("reason", sa.Text(), nullable=False),
)
# The only read this table has: one surface's history, newest first.
op.create_index(
"ix_retrieval_tuning_surface_created",
"retrieval_tuning_events",
["surface", sa.text("created_at DESC")],
)
def downgrade() -> None:
op.drop_index(
"ix_retrieval_tuning_surface_created", table_name="retrieval_tuning_events"
)
op.drop_table("retrieval_tuning_events")
@@ -1,51 +0,0 @@
"""retrieval_tuning_events carries the space each number was measured in (#4104)
Revision ID: 0104
Revises: 0103
Create Date: 2026-09-17
Milestone 416 step 6. 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
measurement of something that no longer exists — and today nothing records
which world it was chosen in, so the staleness is unknowable rather than
merely unknown.
`CHUNKER_VERSION` already solved exactly this for documents: stored per row, so
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
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.
NULLABLE, and not backfilled. The rows already in this table were written
under something, but naming it would be inventing a fact — the honest value is
"unstamped", which is a different answer from a model name that might be wrong.
`current_settings` reports an unstamped dial as exactly that.
"""
import sqlalchemy as sa
from alembic import op
revision = "0104"
down_revision = "0103"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"retrieval_tuning_events",
sa.Column("embedding_model", sa.Text(), nullable=True),
)
op.add_column(
"retrieval_tuning_events",
sa.Column("shape_version", sa.Integer(), nullable=True),
)
def downgrade() -> None:
op.drop_column("retrieval_tuning_events", "shape_version")
op.drop_column("retrieval_tuning_events", "embedding_model")
@@ -1,62 +0,0 @@
"""note_usage_events records WHICH project the reader was in (#4196, #3735)
Revision ID: 0105
Revises: 0104
Create Date: 2026-09-20
Milestone 385. `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:
answerable. Both at once: not, until this column.
WHICH PROJECT THIS IS, because the name is ambiguous and the wrong reading
makes the column useless. It is the project the READER was working in at the
moment of the event — NOT the project the record belongs to. The record's own
project is already on `notes.project_id`; copying it here would answer a
question nobody asked and quietly look like it answered this one.
NULLABLE, and not backfilled. Every existing row was written by a reader who
was somewhere, but naming it would be inventing a fact. Null means "not
reported", which is a different answer from a project id that might be wrong —
the same reasoning 0104 applied to its calibration stamps.
NULL IS ALSO THE ONGOING DEGRADED CASE, not only a historical one. A surfacing
arm always knows the project it searched; a getter only knows what its caller
passed. A pull with no project is still a pull and still counts toward "is this
dead weight" — it simply cannot answer "was it opened away from home".
THE INDEX carries `note_id` first, matching `ix_note_usage_note_event`: every
readout starts from a set of note ids and narrows, never from a project.
"""
import sqlalchemy as sa
from alembic import op
revision = "0105"
down_revision = "0104"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"note_usage_events",
sa.Column("project_id", sa.Integer(), nullable=True),
)
op.create_index(
"ix_note_usage_note_project",
"note_usage_events",
["note_id", "project_id"],
)
def downgrade() -> None:
op.drop_index("ix_note_usage_note_project", table_name="note_usage_events")
op.drop_column("note_usage_events", "project_id")
@@ -1,65 +0,0 @@
"""rule_usage_events carry an outcome, not just a read (#4212, milestone 419)
Revision ID: 0106
Revises: 0105
Create Date: 2026-09-20
Milestone 419's first step. `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:
- a rule surfaced, opened, and followed;
- 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.
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
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.
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
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.
`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
indistinguishable from a miss when someone reads the table back, so the two
states the milestone wants to tell apart would collapse again one layer down.
Nullable because `applied` needs no argument — following a rule is the
unremarkable case, and demanding prose for it would make the cheap event
expensive and stop it being recorded at all.
NO NEW INDEX. Every outcome readout starts from a set of rule ids and narrows
by event, which is exactly `ix_rule_usage_rule_event` (rule_id, event) from
0094. Adding a `detail` index would serve no query anyone has — the column is
read, never filtered on.
"""
import sqlalchemy as sa
from alembic import op
revision = "0106"
down_revision = "0105"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"rule_usage_events",
sa.Column("detail", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("rule_usage_events", "detail")
@@ -1,67 +0,0 @@
"""system_embeddings — a charter becomes an ANSWER, not just a filter (#4251)
Revision ID: 0107
Revises: 0106
Create Date: 2026-09-21
A System's `description` is a charter: several hundred words saying what
belongs in that area and what does not. It is the answer to "which part of
this codebase does X live in", and there was no semantic path to it.
`list_systems` enumerates and `search(system_id=…)` uses a System as a FILTER
over notes — so a System could narrow a search and could never be the answer
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?"
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.
The vectors are DERIVED: nothing is backfilled here, the startup backfill
writes them.
"""
import sqlalchemy as sa
from alembic import op
revision = "0107"
down_revision = "0106"
branch_labels = None
depends_on = None
# Matches its three siblings — bge-small-en-v1.5, 384-dim.
_EMBEDDING_DIM = 384
def upgrade() -> None:
op.create_table(
"system_embeddings",
sa.Column(
"system_id", sa.Integer(),
sa.ForeignKey("systems.id", ondelete="CASCADE"), primary_key=True,
),
sa.Column("chunk_index", sa.Integer(), primary_key=True),
sa.Column("chunk_text", sa.Text(), nullable=False),
sa.Column("chunker_version", sa.Integer(), nullable=False),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
)
# Raw DDL for the vector column, as 0067, 0089 and 0102 do: the type comes
# from the pgvector extension, not SQLAlchemy's type system.
op.execute(
f"ALTER TABLE system_embeddings ADD COLUMN embedding vector({_EMBEDDING_DIM}) NOT NULL"
)
op.execute(
"""
CREATE INDEX ix_system_embeddings_embedding_hnsw
ON system_embeddings
USING hnsw (embedding vector_cosine_ops)
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_system_embeddings_embedding_hnsw")
op.drop_table("system_embeddings")
@@ -1,64 +0,0 @@
"""trigger_leaves_the_stored_title — a snippet's and lesson's title is its name (milestone 427)
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
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
rewrites the rows already stored.
EXACT-SUFFIX, READ FROM THE ROW'S OWN MIRROR. A title is only rewritten when
it ends with `' — ' || <that row's trigger>`, so a subject that legitimately
contains an em dash is never cut, and a row whose title and mirror disagree —
hand-edited through the generic note door — is left alone. Such a row still
embeds correctly (`document_title` is idempotent) and merely shows its old
title; that is the right way round for a data migration to fail.
NO RE-EMBED, AND `updated_at` IS NOT TOUCHED. The embedded text is identical
before and after, so the vectors are already right. The startup backfill
re-embeds any row whose `updated_at` is newer than its vectors, and a raw
UPDATE that leaves `updated_at` alone is what keeps this from queueing the
whole snippet corpus for work that would produce the same numbers. There is
no database trigger on `updated_at`; it is set by the ORM only.
"""
from alembic import op
revision = "0108"
down_revision = "0107"
branch_labels = None
depends_on = None
# kind → the `data` key its trigger is mirrored under (embeddings._TRIGGER_DATA_KEYS).
_KINDS = (("snippet", "when_to_use"), ("lesson", "when_to_apply"))
_SEP = " — "
def upgrade() -> None:
for note_type, key in _KINDS:
op.execute(f"""
UPDATE notes
SET title = btrim(left(title, length(title) - length('{_SEP}' || (data->>'{key}'))))
WHERE note_type = '{note_type}'
AND coalesce(btrim(data->>'{key}'), '') <> ''
AND right(title, length('{_SEP}' || (data->>'{key}'))) = '{_SEP}' || (data->>'{key}')
""")
def downgrade() -> None:
# Recompose, on the same condition inverted: only where the trigger is not
# already on the end, so a downgrade run twice cannot double it.
for note_type, key in _KINDS:
op.execute(f"""
UPDATE notes
SET title = title || '{_SEP}' || (data->>'{key}')
WHERE note_type = '{note_type}'
AND coalesce(btrim(data->>'{key}'), '') <> ''
AND right(title, length('{_SEP}' || (data->>'{key}'))) <> '{_SEP}' || (data->>'{key}')
""")
@@ -1,42 +0,0 @@
"""embeddings_record_their_model — a vector says whose space it lives in (#4132)
Revision ID: 0109
Revises: 0108
Create Date: 2026-09-23
Every embedding table stamps `chunker_version`, so a change to the document
shape is caught per row and re-embedded. None stamped the MODEL. The column is
`vector(384)` — a width, not an identity — so swapping bge-small for any other
384-dim model would write a second geometry beside the first with no error,
and search would go on ranking by cosines between the two, which mean nothing.
`embedding_model` is the other half of `calibration_stamp()`, stored per row on
all four tables. The startup backfill now re-embeds on either half moving.
THE BACKFILL LITERAL IS SAFE BECAUSE NO INSTALL HAS EVER CHANGED MODELS. The
name is hardcoded in `services/embeddings.py`, and every vector ever written was
written by it — so stamping every existing row with that name states a fact,
not a guess. It is frozen here rather than imported: a migration records what
was true when it ran, and a later model change must not rewrite history.
"""
from alembic import op
revision = "0109"
down_revision = "0108"
branch_labels = None
depends_on = None
_TABLES = ("note_embeddings", "rule_embeddings", "milestone_embeddings", "system_embeddings")
_MODEL = "BAAI/bge-small-en-v1.5"
def upgrade() -> None:
for table in _TABLES:
op.execute(f"ALTER TABLE {table} ADD COLUMN embedding_model text")
op.execute(f"UPDATE {table} SET embedding_model = '{_MODEL}'")
op.execute(f"ALTER TABLE {table} ALTER COLUMN embedding_model SET NOT NULL")
def downgrade() -> None:
for table in _TABLES:
op.execute(f"ALTER TABLE {table} DROP COLUMN embedding_model")
-38
View File
@@ -1,38 +0,0 @@
"""task_claims — a task can say a session is working it (milestone 381 step 2)
Revision ID: 0110
Revises: 0109
Create Date: 2026-09-23
`status` is durable and nothing clears it, so `in_progress` cannot also mean
"someone is on this now". The claim is that second meaning, stored as WHO and
WHEN rather than as a flag, so it can be read as dead without anything having
cleared it (`services/task_claims.py` has the semantics).
Four nullable columns, no backfill: no existing row has ever been claimed, and
inventing a claim for one would assert attention nobody observed. No CHECK
enum is touched.
"""
import sqlalchemy as sa
from alembic import op
revision = "0110"
down_revision = "0109"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("notes", sa.Column(
"claimed_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
))
op.add_column("notes", sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("notes", sa.Column("claim_touched_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("notes", sa.Column("claim_session", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("notes", "claim_session")
op.drop_column("notes", "claim_touched_at")
op.drop_column("notes", "claimed_at")
op.drop_column("notes", "claimed_by")
@@ -1,54 +0,0 @@
"""lesson_rule_links — a lesson points at the rule it is an instance of
(milestone 440 step 1, #4196)
Revision ID: 0111
Revises: 0110
Create Date: 2026-10-01
One row per (lesson, rule) pair with a state: `suggested` while evidence
accumulates, `confirmed` or `rejected` once a judgment is made. Only a
confirmed link changes what surfaces; a rejected one is kept so the pair is not
proposed again. CASCADE on both ends — a link to a record that no longer exists
says nothing. No backfill: no lesson has ever been linked, and inventing a link
would assert a judgment nobody made.
"""
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision = "0111"
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).
_STATES = ("suggested", "confirmed", "rejected")
def upgrade() -> None:
op.create_table(
"lesson_rule_links",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column("lesson_id", sa.Integer(), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False),
sa.Column("state", sa.Text(), nullable=False, server_default="suggested"),
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("lesson_id", "rule_id", name="uq_lesson_rule_links_pair"),
)
op.create_check_constraint(
"ck_lesson_rule_links_state", "lesson_rule_links",
"state IN (" + ", ".join(f"'{s}'" for s in _STATES) + ")",
)
op.create_index("ix_lesson_rule_links_lesson_id", "lesson_rule_links", ["lesson_id"])
op.create_index("ix_lesson_rule_links_rule_id", "lesson_rule_links", ["rule_id"])
def downgrade() -> None:
op.drop_index("ix_lesson_rule_links_rule_id", table_name="lesson_rule_links")
op.drop_index("ix_lesson_rule_links_lesson_id", table_name="lesson_rule_links")
op.drop_constraint("ck_lesson_rule_links_state", "lesson_rule_links", type_="check")
op.drop_table("lesson_rule_links")
-33
View File
@@ -1,33 +0,0 @@
"""lesson_no_rule — "no rule fits" is a recorded answer (milestone 440 step 2,
#4631)
Revision ID: 0112
Revises: 0111
Create Date: 2026-10-01
One row per lesson that was judged to fall under no rule, with the reason. A
lesson with neither a confirmed link nor this row is UNJUDGED, which is what
`list_lessons(unjudged=true)` lists. CASCADE from the lesson. No backfill: no
lesson has been judged yet, and writing "no rule fits" for one nobody looked
at would assert a judgment nobody made.
"""
import sqlalchemy as sa
from alembic import op
revision = "0112"
down_revision = "0111"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"lesson_no_rule",
sa.Column("lesson_id", sa.Integer(), sa.ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
sa.Column("why", sa.Text(), nullable=False),
sa.Column("judged_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
)
def downgrade() -> None:
op.drop_table("lesson_no_rule")
@@ -1,34 +0,0 @@
"""system_path_patterns — a System names the files that are its area
(milestone 444 step 3, #4756)
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")
@@ -1,45 +0,0 @@
"""system_usage_events — were an area's rulings read once its files were
touched? (milestone 444 step 4, #4757)
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")
@@ -1,56 +0,0 @@
"""retrieval_judgments — a reviewer's verdict on each line of a logged menu
(#4772)
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
@@ -1,47 +0,0 @@
"""moment_mappings — an install's own corrections to which actions reach which
moment (milestone 458 step 2)
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 (rule 36).
"""
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
@@ -1,38 +0,0 @@
"""rule_moments — the moments of work a rule arrives at (milestone 458 step 3)
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 (step 4) 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")
@@ -1,61 +0,0 @@
"""rule_moment_judgments — whether a rule belongs on a moment, and who said so
(milestone 458 step 7, #4925)
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 (rule 36: 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")
@@ -1,36 +0,0 @@
"""rule_moment_judgments.misfire — a mount reported as arriving where it does
not apply (milestone 458 step 7b, #4955)
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
@@ -1,288 +0,0 @@
"""family canon — platforms, family ideas, the adoption ledger and its decision
log (milestone 463 step 1)
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 (rule 115).
- `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
# (rule 36: 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")
@@ -1,59 +0,0 @@
"""retire the report_preference arm's rows (milestone 500 step 4)
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 (milestone 500 step 3). Its rows go with it (rule
22 — 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 (lesson #5221). `retrieval_logs`
and `retrieval_judgments` are indexed on `source`. `rule_usage_events` is not,
so its delete is also bounded by `created_at` (indexed): the arm shipped with
milestone 409 step 4, decided 2026-09-14, so no row of it 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
@@ -1,82 +0,0 @@
"""Seal forge-connection tokens at rest (milestone 523)
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 scribe.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")
+8 -18
View File
@@ -29,25 +29,15 @@ real Postgres), build (docker buildx).
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
- `jq` + `shellcheck` — apt-installed in the **plugin** job, which lints
the four 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.
- `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`.
far. Both are small (jq ~1 MB, shellcheck ~20 MB) and would be
promotion candidates the moment another project lints shell.
**jq is load-bearing for the smoke test specifically**: every hook
starts with `command -v jq || exit 0`, so without it the test passes
while exercising nothing.
## Notes
+4 -6
View File
@@ -43,10 +43,8 @@ client straight to the URL with a Bearer token.
Authenticate with an API key generated from **Settings → API Keys** (see above),
sent as `Authorization: Bearer fmcp_<key>`. A `read`-scoped key may call only the
read tools (`get_*`, `list_*`, `search`, `enter_project`, `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.
read tools (`get_*`, `list_*`, `search`, `enter_project`); any write/delete tool
is rejected with `403`. A `write`-scoped key may call everything.
### Claude Code (Project-scoped)
@@ -87,9 +85,9 @@ table here. The tools are grouped by family:
| Notes | `create_note`, `get_note`, `update_note`, `delete_note`, `list_notes` | Free-form knowledge |
| Tasks | `create_task`, `update_task`, `add_task_log`, `start_planning` | Actionable work + plans |
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
| Search / Recall | `search`, `get_recent`, `list_tags`, `retrieval_telemetry` | Semantic + structured recall, and the readout its thresholds are tuned from |
| Search / Recall | `search`, `get_recent`, `list_tags` | Semantic + structured recall |
| 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 |
| Rulebooks | `list_always_on_rules`, `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules |
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
| Admin | `get_app_logs` (write/admin key) | Diagnostics |
+6 -8
View File
@@ -76,9 +76,7 @@ endpoint at `/mcp`, not these REST routes.
| Method | Path | Description |
|--------|------|-------------|
| GET / POST | `/api/projects` | List (owned + shared) / create |
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`, `inception`) / update / delete |
| POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) |
| GET | `/api/projects/:id/inception/defaults` | What binds if nobody decides — the inception card's payload |
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`) / update / delete |
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
| GET / PATCH / DELETE | `/api/projects/:id/milestones/:mid` | Read / update / delete |
@@ -115,9 +113,11 @@ endpoint at `/mcp`, not these REST routes.
| GET | `/api/rules` | List rules |
| POST | `/api/rulebook-topics/:tid/rules` | Add a rule to a topic |
| GET / PATCH / DELETE | `/api/rules/:id` | Read / update / delete a rule |
| 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/rulebook-subscriptions` | Subscribe a project to a rulebook |
| GET | `/api/projects/:id/rules` | Applicable rules for a project |
| POST | `/api/projects/:id/rules` | Create a project-scoped rule |
| POST / DELETE | `/api/projects/:id/suppressions/rules/:rid` | Suppress / unsuppress a rule |
| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic |
## Sharing
@@ -169,8 +169,6 @@ endpoint at `/mcp`, not these REST routes.
| GET | `/api/plugin/context` | SessionStart context payload (rules + active-project) |
| GET | `/api/plugin/retrieve` | Title-first knowledge-injection candidates |
| GET | `/api/plugin/processes` | Stored Processes for skill-stub sync |
| GET | `/api/plugin/prior-art` | Write-path hint for the plugin hooks (params: `path`, `code`, `repo`, `shapes`, `exclude_ids`, `exclude_sync_ids`, `exclude_derive`); returns `context`, `note_ids`, `sync_note_ids`, `stamped`, `divergence`, `derive`, `derive_keys` |
| GET / POST | `/api/projects/<id>/coverage`, `…/coverage/refresh` | Shape-ledger accounting (`pattern_coverage` line, counts, `derive_groups` — css groups carry `consumers`, `derive_new`, `unused_css`, `divergence`, `recheck`) |
| GET / PUT | `/api/plugin/marketplace-url` | Read / set the plugin marketplace URL |
## Dashboard, Export, Trash, Users
@@ -203,6 +201,6 @@ endpoint at `/mcp`, not these REST routes.
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
surface — it exposes the same data as typed tools (`create_note`, `create_task`,
`start_planning`, `search`, `enter_project`, …) with
`start_planning`, `search`, `enter_project`, `list_always_on_rules`, …) with
server-level usage guidance delivered in the MCP `instructions` block. See
[API Keys & MCP](api-keys-and-mcp.md).
-160
View File
@@ -1,160 +0,0 @@
# Backups
Scribe can back itself up every day to a **folder** or an **S3-compatible
bucket**. Each run adds one file; Scribe 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:
```
scribe-20261015T020000Z.tar
```
It is a plain, **uncompressed** tar:
- `manifest.json` comes first. It holds the backup format version, the Scribe
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 Scribe 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.)
- **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 Scribe container**, normally a share mounted there:
```yaml
services:
scribe:
volumes:
- /nfs/backup/scribe:/backups
```
Scribe writes each file under a hidden name (`.scribe-….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; Scribe addresses buckets by path.
- **Bucket.**
- **Access key.**
- **Secret key.**
Scribe uploads and lists; it never deletes. A key that may **only add
objects** is therefore enough, and it is the safer key to hand out: a stolen
one cannot wipe the history.
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 Scribe, named `scribe`
for example.
2. Choose how Scribe's files arrive:
- **S3**: turn on the S3 way in for the target and make an **append-only**
S3 key. In Scribe, 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 Scribe as
`/backups/scribe`, and point the target at that folder. In Scribe, choose
**A folder** with `/backups/scribe`.
3. Set the target's **Retention** to **Archives**. Archives then keeps the
newest backups and thins the rest by day, week and month. Scribe 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 Scribe.
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
### Into a fresh instance
Start Scribe on an empty database, copy the backup file into the container,
and run:
```sh
docker exec -it scribe python -m scribe.restore /backups/scribe-20261015T020000Z.tar
```
- The command refuses a database that already has an account. A restore
re-creates everything with new ids, so on a live instance it would add a
second copy instead of replacing anything.
- Nothing limits the file's size.
- It also reads the JSON from the admin **Full Backup** download.
Afterwards, **restart Scribe** so the search indexes are rebuilt. Then issue
new API keys, add forge connections again, and set up any sharing.
### From the web
**Settings → Data → Restore from Backup** takes the `.tar` as well as the
JSON download. The upload is held to the server's request-size limit. For a
bigger file, use the command above.
+1 -2
View File
@@ -9,7 +9,7 @@ Configuration is via environment variables. The `docker-compose.yml` file sets d
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/scribe` | PostgreSQL async connection string |
| `SECRET_KEY` | `dev-secret-change-me` | Session signing key, and the key stored credentials are sealed under — **change this in production, then keep it** |
| `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 |
@@ -127,7 +127,6 @@ If using `TRUST_PROXY_HEADERS=true`, ensure your proxy strips any client-supplie
python3 -c "import secrets; print(secrets.token_hex(32))"
```
Or use Docker Secrets via `SECRET_KEY_FILE`.
- **Keep `SECRET_KEY` once it is set.** Scribe seals the credentials it stores under a key derived from it: forge-connection tokens and the backup destination's S3 secret. A copy of the database alone can't read them. If the key changes (for example, a restore onto an instance with a different key), those credentials can no longer be opened. Settings marks each one and asks for it again, and nothing else breaks.
- **`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.
+7 -20
View File
@@ -45,10 +45,6 @@ Tasks carry status (`todo` → `in_progress` → `done`/`cancelled`), priority
- **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.
@@ -64,15 +60,10 @@ Scribe stores the operator's engineering and workflow **rules** so Claude follow
across sessions.
- **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.
- **Always-on rules** — A rulebook can be flagged always-on; its rules load at the
start of every session through the plugin's push channel.
- **Per-project scope** — A project subscribes to rulebooks, and can add
project-scoped rules or suppress individual inherited rules/topics.
## Stored Processes
@@ -103,7 +94,7 @@ The whole store is reachable by Claude through a built-in **MCP endpoint at `/mc
(Bearer-auth with an API key). The **Scribe Claude Code plugin** (shipped in this
repo) wires it up:
- a `SessionStart` hook that injects active-project
- a `SessionStart` hook that injects the operator's always-on rules + 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;
@@ -127,12 +118,8 @@ Every read and mutation is scoped by owner + direct shares + group shares.
- **Personal export** — download all your notes/tasks as a Markdown ZIP (YAML
frontmatter) or a JSON array.
- **Admin backup** — full application backup/restore with ID remapping on
- **Admin backup** — full application backup/restore (format v4) with ID remapping on
restore for cross-instance migration.
- **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; `python -m scribe.restore` brings a fresh instance back
from one. See [Backups](backups.md).
## Progressive Web App
@@ -152,7 +139,7 @@ reset, and session invalidation are included. See [SSO / OAuth](sso-oauth.md).
| Account | Email change, password change, session invalidation |
| Profile | Per-user profile fields |
| Integrations | SearXNG status |
| Data | Personal export; backup / restore and scheduled backups (admin) |
| 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 |
+23 -36
View File
@@ -7,20 +7,12 @@ import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
import { useSettingsStore } from "@/stores/settings";
import { apiPut } from "@/api/client";
import { fetchVersion } from "@/api/version";
import { apiGet, apiPut } from "@/api/client";
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
// readout whose whole job is to say what is running.
const appVersion = ref<string | null>(null);
const appVersionFailed = ref(false);
const appVersion = ref("dev");
const authStore = useAuthStore();
const settingsStore = useSettingsStore();
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
@@ -127,12 +119,10 @@ onMounted(async () => {
startAppServices();
}
try {
appVersion.value = (await fetchVersion()).version;
const data = await apiGet<{ version: string }>("/api/version");
appVersion.value = data.version;
} catch {
// Not silent any more: the footer says it could not find out, rather than
// showing a version it never received. The full readout (version, channel,
// commit, build) lives in Settings → Config.
appVersionFailed.value = true;
// silent — version display is non-critical
}
});
@@ -161,10 +151,7 @@ onUnmounted(() => {
<div id="main-content" class="app-content">
<router-view />
</div>
<footer class="app-footer">
<span v-if="appVersion">v{{ appVersion }}</span>
<span v-else-if="appVersionFailed">version unknown</span>
</footer>
<footer class="app-footer">v{{ appVersion }}</footer>
</div>
<!-- Keyboard shortcuts overlay -->
@@ -267,7 +254,7 @@ onUnmounted(() => {
left: 0.5rem;
z-index: 9999;
padding: 0.4rem 0.75rem;
background: var(--fs-accent);
background: var(--color-primary);
color: var(--fs-text-on-action);
border-radius: 0 0 4px 4px;
font-size: 0.875rem;
@@ -303,7 +290,7 @@ onUnmounted(() => {
text-align: center;
padding: 0.2rem 0;
font-size: 0.68rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
opacity: 0.45;
user-select: none;
letter-spacing: 0.03em;
@@ -313,17 +300,17 @@ onUnmounted(() => {
.shortcuts-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
z-index: 9000;
display: flex;
align-items: center;
justify-content: center;
}
.shortcuts-panel {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
box-shadow: 0 8px 32px var(--color-shadow);
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 8px);
box-shadow: 0 8px 32px var(--color-shadow, rgba(0,0,0,0.2));
width: min(420px, 92vw);
overflow: hidden;
}
@@ -332,25 +319,25 @@ onUnmounted(() => {
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem 0.75rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.shortcuts-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--fs-text-primary);
color: var(--color-text);
}
.shortcuts-close {
background: none;
border: none;
font-size: 1.4rem;
line-height: 1;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
cursor: pointer;
padding: 0 0.25rem;
}
.shortcuts-close:hover {
color: var(--fs-text-primary);
color: var(--color-text);
}
.shortcuts-body {
padding: 0.75rem 1rem 1rem;
@@ -363,7 +350,7 @@ onUnmounted(() => {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
margin-bottom: 0.4rem;
}
.shortcut-row {
@@ -378,23 +365,23 @@ onUnmounted(() => {
justify-content: center;
min-width: 1.8rem;
padding: 0.15rem 0.4rem;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-bottom-width: 2px;
border-radius: 4px;
font-size: 0.78rem;
font-family: ui-monospace, monospace;
color: var(--fs-text-primary);
color: var(--color-text);
white-space: nowrap;
user-select: none;
}
.shortcut-key-sep {
font-size: 0.78rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.shortcut-desc {
font-size: 0.875rem;
color: var(--fs-text-primary);
color: var(--color-text);
margin-left: 0.25rem;
}
-73
View File
@@ -1,73 +0,0 @@
import { apiGet, apiPost, apiPut } from "@/api/client";
/**
* Scheduled backups (milestone 523) — `/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", {});
}
-81
View File
@@ -1,81 +0,0 @@
/**
* Canonical systems — the GLOBAL area vocabulary every project's Systems can
* map onto (milestone 307).
*
* The mapping is an ASSOCIATION, never a rename: a project's System keeps the
* name the project gave it, and `canonical_id` only records which shared area
* it is an instance of. An unmapped System is fully usable — the catalog is a
* convergence aid, not a gate.
*/
import { apiGet, apiPost, apiPatch, apiPut } from "@/api/client";
export interface CanonicalSystem {
id: number;
name: string;
/** The match key: lowercase, "&" folded to "and", punctuation collapsed. */
slug: string;
description: string | null;
order_index: number;
created_at: string | null;
updated_at: string | null;
}
/**
* A suggested mapping. `basis` is the whole point of showing it:
* - `exact` — the names differ only in spelling. Mechanical.
* - `overlap` — they share a meaningful word. A judgment call the reviewer is
* making, and it must never be presented as if it were the first.
*/
export interface CanonicalMatch {
id: number;
name: string;
basis: "exact" | "overlap";
score?: number;
}
export interface MappingProposal {
system_id: number;
system_name: string;
canonical_id: number;
canonical_name: string;
basis: "exact" | "overlap";
score: number;
}
export async function listCanonicalSystems(): Promise<CanonicalSystem[]> {
const data = await apiGet<{ canonical_systems: CanonicalSystem[] }>(
"/api/canonical-systems",
);
return data.canonical_systems;
}
/** Admin only — a global list anyone can extend stops being shared. */
export async function createCanonicalSystem(data: {
name: string;
description?: string;
}): Promise<CanonicalSystem> {
return apiPost("/api/canonical-systems", data);
}
export async function updateCanonicalSystem(
id: number,
data: Partial<{ name: string; description: string; order_index: number }>,
): Promise<CanonicalSystem> {
return apiPatch(`/api/canonical-systems/${id}`, data);
}
/** Proposals for a project's UNMAPPED Systems. Reads only — nothing applied. */
export async function proposeMappings(projectId: number): Promise<MappingProposal[]> {
const data = await apiGet<{ proposals: MappingProposal[] }>(
`/api/projects/${projectId}/canonical-proposals`,
);
return data.proposals;
}
/** Apply or clear one mapping. `null` unmaps. */
export async function mapSystem(
systemId: number,
canonicalId: number | null,
): Promise<{ id: number; canonical_id: number | null }> {
return apiPut(`/api/systems/${systemId}/canonical`, { canonical_id: canonicalId });
}
+32 -141
View File
@@ -38,135 +38,41 @@ async function handleResponse<T>(res: Response, path: string): Promise<T> {
return res.json() as Promise<T>;
}
/**
* The server's `{"error": "..."}` message from a failed call, or `fallback`
* when the failure carried none (network error, non-JSON body). The one place
* the error envelope is unpacked on the client — views used to restate this
* as a six-line `"body" in e` branch at every catch site.
*/
export function apiErrorMessage(e: unknown, fallback: string): string {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: unknown } }).body;
if (body && typeof body.error === "string" && body.error) return body.error;
}
return fallback;
}
/**
* 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
* 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
* 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
* "the client gave up" rather than anything the server said. */
const CLIENT_TIMEOUT_STATUS = 408;
/**
* How long a STREAM may take to answer with its headers.
*
* Streams are the one case a wall-clock deadline would break: a long-lived SSE
* connection is *supposed* to stay open, and `AbortSignal.timeout` would kill
* it mid-flight along with the body. But that does not exempt them from rule
* 156 — it relocates the deadline. Two different waits are involved:
*
* connect — the server answering with headers. CAN fail to answer, so it
* carries this deadline, cleared the moment headers arrive.
* stream — the body, open indefinitely on purpose. Its failure mode is
* going quiet, which a timeout cannot tell from being idle; that
* is what reconnection and Last-Event-ID are for, not this.
*
* Reading the connect as exempt because "the stream is long-lived" is the easy
* mistake here, and it leaves an unreachable server looking like a quiet one.
*/
const STREAM_CONNECT_TIMEOUT_MS = 15_000;
/**
* A signal that aborts if headers do not arrive in time, plus the `settle` to
* call once they do. After `settle()` the returned signal never fires, so the
* stream body runs unbounded — which is the intent.
*/
function connectDeadline(base: AbortSignal): { signal: AbortSignal; settle: () => void } {
const gate = new AbortController();
const timer = setTimeout(
() => gate.abort(new DOMException("stream did not connect in time", "TimeoutError")),
STREAM_CONNECT_TIMEOUT_MS,
);
return {
signal: AbortSignal.any([base, gate.signal]),
settle: () => clearTimeout(timer),
};
}
export interface RequestOpts {
/** Override the deadline. Pass one when the call is slow BY DESIGN. */
timeoutMs?: number;
}
/**
* 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
* 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
* generic fallback and the timeout would be invisible in the very situation it
* exists to expose. Rethrowing as `ApiError` means ~330 existing call sites
* report it correctly without being touched.
*
* Only a TIMEOUT is converted. A deliberate cancellation aborts with
* `AbortError` and is left alone — a caller that cancelled its own request
* does not want it reported as a server failure.
*/
async function request<T>(path: string, init: RequestInit, opts?: RequestOpts): Promise<T> {
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
let res: Response;
try {
res = await fetch(path, { ...init, signal: AbortSignal.timeout(timeoutMs) });
} catch (e) {
if (e instanceof DOMException && e.name === "TimeoutError") {
throw new ApiError(CLIENT_TIMEOUT_STATUS, {
error: `The server did not answer within ${Math.round(timeoutMs / 1000)}s.`,
});
}
throw e;
}
export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(path);
return handleResponse<T>(res, path);
}
/** JSON body headers — the three write verbs sent an identical literal each. */
const JSON_HEADERS = { "Content-Type": "application/json" };
export function apiGet<T>(path: string, opts?: RequestOpts): Promise<T> {
return request<T>(path, {}, opts);
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return handleResponse<T>(res, path);
}
export function apiPost<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
return request<T>(path, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return handleResponse<T>(res, path);
}
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);
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return handleResponse<T>(res, path);
}
export function apiPatch<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
return request<T>(path, { method: "PATCH", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
}
export function apiDelete(path: string, opts?: RequestOpts): Promise<void> {
return request<void>(path, { method: "DELETE" }, opts);
export async function apiDelete(path: string): Promise<void> {
const res = await fetch(path, { method: "DELETE" });
return handleResponse<void>(res, path);
}
// ---------------------------------------------------------------------------
@@ -301,14 +207,7 @@ export function apiSSEStream(
}
const done = (async () => {
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
const connect = connectDeadline(combinedSignal);
let res: Response;
try {
res = await fetch(path, { headers, signal: connect.signal });
} finally {
connect.settle();
}
const res = await fetch(path, { headers, signal: combinedSignal });
if (!res.ok) {
let body: Record<string, unknown> = {};
try {
@@ -405,19 +304,11 @@ export async function apiStreamPost(
body: unknown,
onChunk: (data: Record<string, unknown>) => void
): Promise<void> {
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
const connect = connectDeadline(new AbortController().signal);
let res: Response;
try {
res = await fetch(path, {
method: "POST",
headers: JSON_HEADERS,
body: JSON.stringify(body),
signal: connect.signal,
});
} finally {
connect.settle();
}
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
let errBody: Record<string, unknown> = {};
try {
+9
View File
@@ -0,0 +1,9 @@
import { apiGet } from "@/api/client";
import type { ExpectationResponse } from "@/utils/designDrift";
/** Checkable claims from the rulebook this install designated as its design system.
*
* `rulebook_id: null` means none has been designated — the normal state for a
* fresh install, not an error. The caller shows an explanatory empty state. */
export const fetchDesignExpectations = () =>
apiGet<ExpectationResponse>("/api/design/expectations");
+3 -28
View File
@@ -74,28 +74,11 @@ export const fetchDesignSystems = () =>
export const fetchDesignSystem = (id: number) =>
apiGet<DesignSystem>(`/api/design-systems/${id}`);
export interface StarterRoleGroup {
group: string;
description: string;
token_count: number;
names: string[];
}
/** 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. */
export const listStarterRoleGroups = () =>
apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>(
"/api/design-systems/starter-roles",
);
export const createDesignSystem = (body: {
title: string;
description?: string;
guidance?: string;
parent_id?: number | null;
starter_role_groups?: string[];
token_prefix?: string;
}) => apiPost<DesignSystem>("/api/design-systems", body);
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
@@ -200,14 +183,6 @@ export interface SnippetCheck {
findings: SnippetFinding[];
}
/** Which recorded snippets disagree with this design system's sheet.
*
* `projectId` narrows to the snippets one project owns — which is how a
* project asks about its OWN code. Omit it to check every project, which is
* the right default from the system's side: a component recorded elsewhere
* still has to use the same tags. */
export const checkSnippets = (id: number, projectId?: number) =>
apiGet<SnippetCheck>(
`/api/design-systems/${id}/snippet-check`
+ (projectId ? `?project_id=${projectId}` : ""),
);
/** Which recorded snippets disagree with this design system's sheet. */
export const checkSnippets = (id: number) =>
apiGet<SnippetCheck>(`/api/design-systems/${id}/snippet-check`);
-169
View File
@@ -1,169 +0,0 @@
/**
* Family canon (milestone 463): ideas every project on a platform shares, and
* the log of every decision about them.
*
* The agent promotes by written criteria — no approval step. This door is for
* reading what was decided and why, and for retiring an idea or undoing a
* decision when a person disagrees.
*/
import { apiGet, apiPost } from "@/api/client";
export type IdeaStatus = "candidate" | "canon" | "retired";
export type DecisionAction = "propose" | "promote" | "revise" | "retire" | "assess" | "undo";
export interface Criterion {
key: string;
title: string;
test: string;
}
export interface IdeaTopic {
id: number;
title: string;
rulebook_id: number;
/** Present on a single idea's read, not on the list. */
rules?: { id: number; title: string }[];
}
export interface FamilyIdea {
note_id: number;
status: IdeaStatus;
applies_when: string;
canon_version: number;
topic_id: number | null;
/** The rule topic holding the idea's binding norms; null when none is
* linked or the reader does not own its rulebook. */
topic: IdeaTopic | null;
title: string;
note_type: string;
is_task: boolean;
project_id: number | null;
platforms: string[];
created_at: string | null;
updated_at: string | null;
}
/** A state snapshot a decision records — slugs, never ids. */
export interface IdeaSnapshot {
status: IdeaStatus;
applies_when: string;
canon_version: number;
platforms: string[];
}
export type AdoptionStatus = "unassessed" | "adopted" | "variant" | "exempt" | "owed";
/** One project's answer as a decision records it. */
export interface AdoptionSnapshot {
status: AdoptionStatus;
reason: string;
canon_version: number | null;
}
/**
* A decision about the idea itself (project_id null) snapshots the idea; a
* project's assessment (project_id set) snapshots that project's answer.
*/
export interface FamilyDecision {
id: number;
idea_id: number;
idea_title?: string;
project_id: number | null;
project_title?: string | null;
action: DecisionAction;
reason: string;
before: IdeaSnapshot | AdoptionSnapshot | null;
after: IdeaSnapshot | AdoptionSnapshot | null;
evidence: Record<string, unknown>;
precedent_ids: number[];
decided_via: "agent" | "operator" | "system";
user_id: number | null;
created_at: string | null;
undoable?: boolean;
}
/** One ground of the conflict order, in order. */
export interface ConflictGround {
key: string;
title: string;
test: string;
fold_as: "alternative" | "trap" | "condition";
}
export async function listFamilyIdeas(
status = "",
): Promise<{ ideas: FamilyIdea[]; criteria: Criterion[]; conflict_order?: ConflictGround[] }> {
const q = status ? `?status=${encodeURIComponent(status)}` : "";
return apiGet(`/api/family/ideas${q}`);
}
export async function listFamilyDecisions(limit = 50, offset = 0): Promise<FamilyDecision[]> {
const data = await apiGet<{ decisions: FamilyDecision[] }>(
`/api/family/decisions?limit=${limit}&offset=${offset}`,
);
return data.decisions;
}
export async function undoFamilyDecision(id: number, reason: string) {
return apiPost<{ decision: FamilyDecision }>(`/api/family/decisions/${id}/undo`, { reason });
}
export async function retireFamilyIdea(noteId: number, reason: string) {
return apiPost<{ decision: FamilyDecision }>(`/api/family/ideas/${noteId}/retire`, { reason });
}
export interface Outcome {
key: AdoptionStatus;
title: string;
test: string;
}
/** One project's answer to one canon idea — a cell of the matrix. */
export interface AdoptionCell {
project_id: number;
project_title: string;
idea_id: number;
idea_title: string;
idea_version: number;
/** The project is on one of the idea's platforms. */
in_scope: boolean;
/** A ledger row exists. False: the idea reaches the project, nobody asked yet. */
reached: boolean;
status: AdoptionStatus;
reason: string;
canon_version: number | null;
assessed_at: string | null;
decided_via: "agent" | "operator" | "system" | null;
/** Answered against a canon version that is not the current one. */
needs_recheck: boolean;
owed_task: { id: number; title: string; status: string } | null;
/** Live shapes in the project classified against one of the idea's references. */
shapes: { path: string; symbol: string; status: "canonical" | "instance" | "variant" }[];
}
export interface MatrixIdea {
note_id: number;
title: string;
note_type: string;
is_task: boolean;
canon_version: number;
applies_when: string;
platforms: string[];
}
export interface AdoptionMatrix {
ideas: MatrixIdea[];
projects: { id: number; title: string; platforms: string[] }[];
cells: AdoptionCell[];
outcomes: Outcome[];
}
export async function fetchAdoptionMatrix(
opts: { platform?: string; projectId?: number } = {},
): Promise<AdoptionMatrix> {
const q = new URLSearchParams();
if (opts.platform) q.set("platform", opts.platform);
if (opts.projectId) q.set("project_id", String(opts.projectId));
const qs = q.toString();
return apiGet(`/api/family/matrix${qs ? `?${qs}` : ""}`);
}
-49
View File
@@ -1,49 +0,0 @@
/** Project inception (milestone 297): what a project was decided to inherit. */
import { apiGet, apiPost } from "@/api/client";
import type { ProjectPlatform } from "@/api/platforms";
export interface InceptionChoices {
design_system_id: number | null;
seed_systems: boolean;
/** Platform slugs (milestone 463). null = not answered; a list is the
* whole answer — members left out of it are recorded as "not this".
* Absent on records decided before the question existed. */
platforms?: string[] | null;
}
export interface InceptionRecord {
decided_at: string;
decided_by: number | null;
via: "mcp" | "ui" | "legacy";
choices: InceptionChoices;
}
export interface InceptionDefaults {
design_system_id: number | null;
design_systems: { id: number; title: string }[];
systems: number;
platforms: { slug: string; name: string }[];
/** What detection (or an earlier answer) already says about the project. */
project_platforms: ProjectPlatform[];
}
export interface InceptionDecision {
project_id: number;
inception: InceptionRecord;
effects: {
design_system_id: number | null;
systems_seeded: string[];
/** The project's answers after the decision; null when left unstated. */
platforms: ProjectPlatform[] | null;
};
}
export const emptyChoices = (): InceptionChoices => ({
design_system_id: null, seed_systems: false, platforms: null,
});
export const fetchInceptionDefaults = (projectId: number) =>
apiGet<InceptionDefaults>(`/api/projects/${projectId}/inception/defaults`);
export const decideInception = (projectId: number, choices: InceptionChoices) =>
apiPost<InceptionDecision>(`/api/projects/${projectId}/inception`, { choices });
-234
View File
@@ -1,234 +0,0 @@
import type { RecordUsage } from "@/types/usage";
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
import type { RuleKind } from "@/api/rulebooks";
/** Where a lesson stands on "which rule is this an instance of?"
* (`services/lesson_rules.py`: LINKED / NO_RULE / UNJUDGED). Three answers,
* not two: "looked at and found to stand alone" and "nobody has looked" are
* different facts, and the second is the one worth acting on. A rejected link
* alone leaves a lesson unjudged — "not that rule" does not say whether
* another one fits. */
export type RuleJudgment = "linked" | "no_rule" | "unjudged";
/** A link's state (`models/lesson_rule_link.py` LINK_STATES). */
export type LinkState = "suggested" | "confirmed" | "rejected";
/** One link from a lesson to a rule, in the reader's view: only rules the
* reader owns are listed (`rules_for_lessons`). */
export interface LessonRuleLink {
id: number;
title: string;
kind: RuleKind;
/** `suggested` carries nothing in retrieval until it is judged; only
* `confirmed` changes what surfaces; `rejected` is kept so the pair is
* never proposed again. */
state: LinkState;
/** Why it was confirmed or rejected. */
note: string;
/** What a suggestion rests on — sent on suggested links only. */
evidence?: { situations: number; projects: number; co_surfaced: number };
}
/** A lesson: a transferable insight, retrievable by the SITUATION it applies
* to rather than by its topic.
*
* The fields mirror what the backend composes and reads back
* (`services/lessons.py::lesson_to_dict`), not the stored row. `title` and
* `body` are DERIVED — the service builds them from `what`, `when_to_apply`
* and `insight` — so an editor sends the three parts and never the document.
* That is the whole design: the trigger ends up in the title and again at the
* head of the body, which is what makes a lesson rank on when it applies. */
export interface Lesson {
id: number;
/** The composed document title, `{what} — {when_to_apply}`. Read-only. */
title: string;
/** The composed body. Read-only — edit `insight` instead. */
body: string;
/** The claim itself, as you would say it. */
what: string;
/** WHEN this applies — the situation, in the words it presents itself in.
* The entire retrieval story: a lesson without one saves, reads correctly
* and never surfaces, so both doors refuse an empty one. */
when_to_apply: string;
/** The body with the composed lines stripped — what an edit form binds to,
* so saving doesn't accumulate a copy of the trigger line per save. */
insight: string;
/** Ids of the records that taught this — issues, tasks or notes. */
learned_from: number[];
/** The same sources RESOLVED, sent by the detail route only. A bare "#4181"
* on a page tells a reader nothing about whether it is worth opening, and
* the provenance is the point of a lesson — one that loses its incidents
* loses its evidence. A source that has been deleted drops out rather than
* rendering a link to nothing. */
learned_from_records?: {
id: number;
title: string;
note_type: string;
is_task: boolean;
task_kind: string | null;
status: string | null;
}[];
tags: string[];
note_type: string;
/** Where it was LEARNED. Kept as a fact, but not a limit on where it can be
* found: a lesson is retrievable from every project (milestone 385 step 3). */
project_id: number | null;
permission?: string;
created_at: string | null;
updated_at: string | null;
systems?: { id: number; name: string }[];
usage?: RecordUsage;
/** The rules this lesson points at, confirmed first, then suggested, then
* rejected. Absent (with `rule_judgment`) when the links could not be read
* — which is "not attached", never "no rule". */
rules?: LessonRuleLink[];
rule_judgment?: RuleJudgment;
/** The "no rule fits" answer, present when that is the judgment. */
no_rule?: { why: string; judged_at: string | null };
/** Sent by a create or update that recorded "no rule fits" when that answer
* completes a group: lessons with no rule that keep landing in one
* situation, which may want a rule written for it (#4634). */
convergence?: { lessons: { id: number; title: string }[] };
/** Set when another user owns this record. */
shared?: boolean;
owner?: string | null;
}
/** A row in the browse listing — the trigger travels with it, because a list
* of lessons without their triggers is a list of claims with the half that
* says when each one matters left off. */
export interface LessonListRow {
id: number;
title: string;
tags: string[];
when_to_apply?: string;
snippet?: string;
/** Surfaced-vs-opened for this lesson. Always present on a listing from
* this door, zero-filled where nothing has been recorded — absent only
* when a row came from somewhere else. */
usage?: RecordUsage;
shared?: boolean;
owner?: string | null;
}
export interface LessonListResponse {
lessons: LessonListRow[];
total: number;
}
/** What the create/update forms send. `what` and `when_to_apply` are required
* on create; every field is optional on update, and the service re-composes
* the whole document from the merged set — so a partial save can never leave
* the title and body disagreeing about the trigger. */
export interface LessonPayload {
what?: string;
when_to_apply?: string;
insight?: string;
learned_from?: number[];
tags?: string[];
project_id?: number | null;
system_ids?: number[];
/** The rules this lesson is an instance of. On update, a list is the full
* new set: a confirmed rule left out is judged NOT an instance and becomes
* rejected. Omit it to leave the links alone. */
rule_ids?: number[];
/** "No rule fits", with the reason. Exclusive with a non-empty `rule_ids`:
* the server refuses both in one request. */
no_rule?: string;
/** Deliberate override of the near-duplicate gate, once the writer has seen
* the warning. Two lessons under one trigger compete for one reserved slot,
* so a duplicate displaces rather than merely clutters. */
force?: boolean;
}
export function listLessons(params: {
q?: string;
tag?: string;
project_id?: number;
limit?: number;
offset?: number;
} = {}): Promise<LessonListResponse> {
const qs = new URLSearchParams();
if (params.q) qs.set("q", params.q);
if (params.tag) qs.set("tag", params.tag);
if (params.project_id) qs.set("project_id", String(params.project_id));
if (params.limit != null) qs.set("limit", String(params.limit));
if (params.offset != null) qs.set("offset", String(params.offset));
const suffix = qs.toString() ? `?${qs}` : "";
return apiGet<LessonListResponse>(`/api/lessons${suffix}`);
}
export function getLesson(id: number): Promise<Lesson> {
return apiGet<Lesson>(`/api/lessons/${id}`);
}
export function createLesson(payload: LessonPayload): Promise<Lesson> {
return apiPost<Lesson>("/api/lessons", payload);
}
export function updateLesson(
id: number,
payload: LessonPayload,
): Promise<Lesson> {
return apiPatch<Lesson>(`/api/lessons/${id}`, payload);
}
/** A rule the lesson being written resembles (`rule_candidates`). */
export interface RuleCandidate {
id: number;
title: string;
kind: RuleKind;
when_to_apply: string;
score: number;
}
/** The rules a lesson resembles, asked BEFORE it is saved so the writer can
* name one in the same save. `null` means the search could not run, which is
* different from an empty list ("nothing resembles it"). */
export function ruleCandidates(params: {
what: string;
when_to_apply: string;
project_id?: number | null;
}): Promise<{ candidates: RuleCandidate[] | null }> {
const qs = new URLSearchParams({
what: params.what,
when_to_apply: params.when_to_apply,
});
if (params.project_id) qs.set("project_id", String(params.project_id));
return apiGet<{ candidates: RuleCandidate[] | null }>(
`/api/lessons/rule-candidates?${qs}`,
);
}
/** Confirm or reject one lesson→rule link. Confirming also clears a "no rule
* fits" answer: the two cannot both be the current answer. */
export function judgeLessonLink(
lessonId: number,
ruleId: number,
verdict: "confirm" | "reject",
note = "",
): Promise<unknown> {
return apiPut(`/api/lessons/${lessonId}/rules/${ruleId}`, { verdict, note });
}
/** Trash, not erase — recoverable. `apiDelete` discards the body, which is the
* established shape here (snippets delete the same way): the batch id is in
* the response, but no caller has needed it and inventing a second delete
* helper to carry it would be the duplication, not the feature. */
export function deleteLesson(id: number): Promise<void> {
return apiDelete(`/api/lessons/${id}`);
}
/** The lessons drawn FROM one record — the reverse of `learned_from`.
*
* The direction that gets forgotten, and arguably the more useful one: a
* reader opening an old issue wants to know what was learned from it, and
* without this the relation is only navigable from the lesson's side. */
export function lessonsTaughtBy(
recordId: number,
): Promise<{ lessons: Lesson[]; taught_by: number }> {
return apiGet<{ lessons: Lesson[]; taught_by: number }>(
`/api/lessons/taught-by/${recordId}`,
);
}
-173
View File
@@ -1,173 +0,0 @@
import { apiGet, apiPost, apiDelete } from "@/api/client";
/**
* The moments of work rules mount on (milestone 458), and the actions that
* reach each one on this install — `GET /api/retrieval/moments`, the payload
* the `list_moments` MCP tool returns, plus the counts the Settings view shows
* beside each moment.
*/
export interface Moment {
name: string;
/** What is happening at this moment. */
means: string;
/** The kinds of action that typically reach it. */
reached_by: string;
}
/** A shape rather than an entry: `skill.<name>`, one moment per procedure. */
export interface MomentFamily {
name: string;
prefix: string;
means: string;
reached_by: string;
}
/** `default` ships with the product; `install` is this install's own. */
export type ActionVia = "default" | "install";
export interface MomentAction {
tool: string;
/** Empty = every call of the tool. A command prefix for a command tool,
* `field=value` pairs for any other. */
match: string;
via: ActionVia;
}
/** A shipped default this install switched off. */
export interface RemovedDefault {
id: number;
tool: string;
match: string;
moment: string;
reason: string | null;
actor: string | null;
created_at: string | null;
}
export interface MomentUsage {
/** How many times a mounted rule arrived at this moment. */
delivered: number;
/** How many distinct rules did. */
rules: number;
/** Of those, how many an agent then opened — an upper bound: a pull records
* the door, not the line that prompted it. */
opened: number;
last_delivered_at: string | null;
}
export interface MomentsPayload {
moments: Moment[];
families: MomentFamily[];
total: number;
actions: Record<string, MomentAction[]>;
removed_defaults: RemovedDefault[];
/** Rules mounted per moment; a moment carrying none is absent. */
mounted: Record<string, number>;
/** The count read failed — `mounted` is empty for that reason, not because
* nothing is mounted. */
mounted_failed?: boolean;
usage: {
by_moment: Record<string, MomentUsage>;
days: number;
/** Same distinction as `mounted_failed`, for the usage read. */
moment_usage_failed?: boolean;
};
}
export interface MappingChange {
tool: string;
match?: string;
moment: string;
reason?: string;
}
export function getMoments(days = 30): Promise<MomentsPayload> {
return apiGet(`/api/retrieval/moments?days=${days}`);
}
export function mapAction(change: MappingChange): Promise<unknown> {
return apiPost("/api/retrieval/moments/mappings", change);
}
/** Query parameters, not a body: DELETE bodies are not reliably sent. */
export function unmapAction(change: MappingChange): Promise<void> {
const q = new URLSearchParams({
tool: change.tool,
match: change.match ?? "",
moment: change.moment,
reason: change.reason ?? "",
});
return apiDelete(`/api/retrieval/moments/mappings?${q.toString()}`);
}
/**
* Proposals about a rule's moments, waiting on a person. A `mount` proposal
* (milestone 458 step 7) comes from a pass that read the rule, or from the
* rule being opened just after the moment fired. An `unmount` proposal (step
* 7b) comes from sessions reporting that the mount arrived where it did not
* apply. `GET /api/retrieval/moments/proposals`, the payload the
* `rule_moment_proposals` MCP tool returns.
*/
export type ProposalSource = "pass" | "signal" | "edit" | "misfire";
export interface MisfireReason {
why: string;
reached_by: string;
at: string;
}
export interface MomentProposal {
proposal: "mount" | "unmount";
moment: string;
source: ProposalSource;
why: string;
/** For a misfire, `situations` counts distinct days and `co_surfaced` the reports. */
evidence: { situations: number; projects: number; co_surfaced: number };
created_at: string | null;
/** Unmount proposals only: the newest reasons given, and how often each action reached the moment. */
reasons?: MisfireReason[];
reached_by?: Record<string, number>;
}
export interface RuleProposals {
id: number;
title: string;
kind: string;
statement: string;
when_to_apply: string;
home: "global" | "project";
/** What the rule is mounted on now. */
mounted: string[];
proposals: MomentProposal[];
}
export interface ProposalsPayload {
rules: RuleProposals[];
total: number;
}
export type Verdict = "confirm" | "reject";
export interface MomentJudgment {
rule_id: number;
moment: string;
verdict: Verdict;
note?: string;
}
export interface JudgeResult {
judged: { rule_id: number; moment: string; state: string }[];
refused: { rule_id: number; moment?: string; error: string }[];
}
export function getProposals(ruleId?: number): Promise<ProposalsPayload> {
return apiGet(`/api/retrieval/moments/proposals${ruleId ? `?rule_id=${ruleId}` : ""}`);
}
/**
* A confirm MOUNTS the rule on the moment (on an unmount proposal, keeps it);
* a reject unmounts it if mounted and stops the pair being proposed again.
*/
export function judgeProposals(judgments: MomentJudgment[]): Promise<JudgeResult> {
return apiPost("/api/retrieval/moments/proposals/judge", { judgments });
}
-77
View File
@@ -1,77 +0,0 @@
/**
* Platforms — what a project is built on or ships as (milestone 463).
*
* The catalog is GLOBAL, like the canonical areas, and admin-written. A
* project's answer per platform is `declared` (a person said so), `detected`
* (a marker file in a bound repo said so) or `rejected` (a person said no —
* kept, so the next refresh cannot detect it back). Only declared and
* detected make a project a member, and membership is what decides which
* family ideas reach it.
*/
import { apiGet, apiPatch, apiPost, apiPut } from "@/api/client";
export interface Platform {
id: number;
name: string;
/** The match key, and the name every door takes (stable across restores). */
slug: string;
description: string | null;
/** Repo-relative globs. A bare name matches a basename anywhere; one with
* a slash matches the whole path. Empty = declare-only. */
markers: string[];
order_index: number;
created_at: string | null;
updated_at: string | null;
}
export type PlatformState = "declared" | "detected" | "rejected";
/** What a person may set. `null` withdraws the answer. */
export type SettablePlatformState = "declared" | "rejected" | null;
export interface ProjectPlatform {
id: number;
slug: string;
name: string;
state: PlatformState;
}
export const MEMBER_STATES: PlatformState[] = ["declared", "detected"];
export async function listPlatforms(): Promise<Platform[]> {
const data = await apiGet<{ platforms: Platform[] }>("/api/platforms");
return data.platforms;
}
/** Admin only. A name that reduces to an existing slug answers 409. */
export async function createPlatform(data: {
name: string;
description?: string;
markers?: string[];
}): Promise<Platform> {
return apiPost("/api/platforms", data);
}
export async function updatePlatform(
id: number,
data: Partial<{ name: string; description: string; order_index: number; markers: string[] }>,
): Promise<Platform> {
return apiPatch(`/api/platforms/${id}`, data);
}
export async function fetchProjectPlatforms(projectId: number): Promise<ProjectPlatform[]> {
const data = await apiGet<{ project_platforms: ProjectPlatform[] }>(
`/api/projects/${projectId}/platforms`,
);
return data.project_platforms;
}
/** Only the slugs named change; the update applies whole or not at all. */
export async function setProjectPlatforms(
projectId: number,
platforms: Record<string, SettablePlatformState>,
): Promise<ProjectPlatform[]> {
const data = await apiPut<{ project_platforms: ProjectPlatform[] }>(
`/api/projects/${projectId}/platforms`, { platforms },
);
return data.project_platforms;
}
-47
View File
@@ -1,47 +0,0 @@
import { apiGet } from "@/api/client";
/**
* The reply shapes Scribe ships (milestone 500) — `GET /api/retrieval/reply-shapes`.
* The same catalog the `list_reply_shapes` MCP tool returns, plus what only
* the operator's view needs: the preferences mounted on each shape's moment,
* and how often each shape was delivered.
*/
export interface ShapePreference {
id: number;
title: string;
statement: string;
}
/** How often a shape went out: whole, or as its one-line reminder. */
export interface ShapeDeliveries {
full: number;
pointer: number;
}
export interface ReplyShape {
key: string;
title: string;
/** The moment it rides — a preference mounted here adjusts it. */
moment: string;
/** What is happening at that moment. */
means: string;
/** When the shape arrives, in words. */
delivered: string;
/** The shipped text. Product, so read-only. */
text: string;
preferences: ShapePreference[];
/** null when the counts could not be read — unknown, not zero. */
deliveries: ShapeDeliveries | null;
}
export interface ReplyShapesOverview {
shapes: ReplyShape[];
total: number;
days: number;
deliveries_failed: boolean;
}
export async function getReplyShapes(days?: number): Promise<ReplyShapesOverview> {
const q = days ? `?days=${days}` : "";
return apiGet<ReplyShapesOverview>(`/api/retrieval/reply-shapes${q}`);
}
+51 -286
View File
@@ -1,31 +1,11 @@
import type { RecordUsage } from "@/types/usage";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
import type { LinkState } from "@/api/lessons";
/** How a rule reaches a session (milestone 307). */
/**
* A typed edge between two rules. Each kind exists because its absence forced
* a workaround: merging two rules into one row, writing an override as a
* near-copy, or leaving a local addendum with nothing to say it is one.
*/
export type RuleRelationKind = "co_surfaces" | "overrides" | "elaborates";
export interface RuleRelation {
id: number;
kind: RuleRelationKind;
/** The rule at the OTHER end. */
rule_id: number;
direction: "outgoing" | "incoming";
note: string;
}
export interface Rulebook {
id: number;
owner_user_id: number;
title: string;
description: string;
always_on: boolean;
created_at: string | null;
updated_at: string | null;
}
@@ -40,109 +20,57 @@ export interface RulebookTopic {
updated_at: string | null;
}
/**
* What kind of instruction this is, and it is about FORCE, not importance.
*
* A `rule` must be FOLLOWED — ignoring it breaks something. It is the
* operator's decision, so it changes when they change it. A `preference` is
* how they want work DONE — ignoring it costs consistency, not correctness —
* and the agent rewrites it in the ordinary course of working, which is what
* makes the drift surface necessary.
*
* Always sent by the server, never inferred from an absent key: "no kind
* field" and "kind is rule" must not be the same payload.
*/
export type RuleKind = "rule" | "preference";
/** A new record opened from somewhere that already knows what it is for —
* "Adjust this" on a reply shape opens a preference mounted on that shape's
* moment (milestone 500 step 5). The editor applies it once, when creating. */
export interface RulePreset {
kind?: RuleKind;
moments?: string[];
whenToApply?: string;
}
export interface Rule {
id: number;
topic_id: number | null;
project_id: number | null;
title: string;
statement: string;
kind: RuleKind;
/** WHEN this rule fires — the trigger, not the instruction. */
when_to_apply: string;
why: string;
how_to_apply: string;
/**
* How to check the rule is still true, and the state that ends it. Set
* only on a rule that asserts a fact about something outside the
* operator's control; empty on a rule that is a decision, which is most
* of them. Empty is meaningful, not missing.
*/
verify_with: string;
expires_when: string;
/** When the check last passed. Null means never checked. */
verified_at: string | null;
/** The note or task that caused this rule, if one was recorded. */
arose_from_id: number | null;
order_index: number;
created_at: string | null;
updated_at: string | null;
/** Present only when the rule has them (the server omits empty keys). */
systems?: { id: number; name: string }[];
/** The moments this rule arrives at whenever they happen (milestone 458),
* in catalog order. Present only when the rule is mounted. */
moments?: string[];
relations?: RuleRelation[];
/** The lessons that point at this rule (milestone 440) — the concrete
* situations judged instances of it, plus any suggested and awaiting a
* judgment. Readable lessons only; omitted when there are none. */
lessons?: { id: number; title: string; state: LinkState; note: string }[];
}
/**
* A rule as a LIST ROW — services.rulebooks.rule_brief's output. Carries the
* age deliberately: a rule written before the capability it duplicates is
* otherwise indistinguishable, at a glance, from one still doing work.
*/
export interface RuleHeader {
id: number;
title: string;
statement: string;
topic_id: number | null;
/** Unconditional on the wire (services.rulebooks.rule_brief). */
kind: RuleKind;
/** A date (YYYY-MM-DD), not a timestamp. */
updated_at: string | null;
when_to_apply?: string;
arose_from_id?: number;
/**
* Present ONLY on a rule that carries a check — the presence of the key
* is itself the signal that this rule asserts a fact that can go false.
* A date (YYYY-MM-DD), or the literal "never".
*/
last_verified?: string;
/**
* Surfaced-vs-opened counts from `rule_usage_events` (milestone 333).
* Zero-filled by the list route, so a rule predating the table reads as
* "never surfaced" rather than as a missing field — which for a while is
* every rule on every install.
*/
usage?: RecordUsage;
}
export interface ApplicableRules {
// Both lists are rule_brief's output — the SAME builder, so they are
// described the same way here rather than as two hand-written shapes that
// drift from it and from each other (which is what the server side had).
rules: (RuleHeader & {
rules: {
id: number;
title: string;
statement: string;
topic_id: number;
topic_title: string;
rulebook_id: number;
rulebook_title: string;
})[];
project_rules: RuleHeader[];
}[];
project_rules: {
id: number;
title: string;
statement: string;
}[];
suppressed_rules: {
id: number;
title: string;
topic_id: number;
topic_title: string;
rulebook_id: number;
rulebook_title: string;
}[];
suppressed_topics: {
id: number;
title: string;
rulebook_id: number;
rulebook_title: string;
}[];
truncated: boolean;
subscribed_rulebooks: { id: number; title: string }[];
}
// ── Rulebooks ───────────────────────────────────────────────────────
@@ -160,7 +88,7 @@ export async function createRulebook(data: { title: string; description?: string
return apiPost("/api/rulebooks", data);
}
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string }>): Promise<Rulebook> {
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string; always_on: boolean }>): Promise<Rulebook> {
return apiPatch(`/api/rulebooks/${id}`, data);
}
@@ -203,154 +131,27 @@ export async function getRule(id: number): Promise<Rule> {
return apiGet(`/api/rules/${id}`);
}
/**
* The fields both write paths accept. `system_ids` REPLACES a rule's areas.
*
* Sending "" for a nullable text field CLEARS it here — the server maps an
* empty string to NULL, so an emptied form input does what it looks like it
* does. (The MCP door reads "" as "leave unchanged" and needs an explicit
* clear_fields list instead; the two idioms reach the same state.)
*/
export interface RuleWrite {
title: string;
statement: string;
/** Writable from the editor: a preference is not a lesser rule, it is a
* different force, and the person writing it is the one who knows which. */
kind: RuleKind;
when_to_apply: string;
why: string;
how_to_apply: string;
order_index: number;
system_ids: number[];
/** Replaces the set; [] unmounts the rule from every moment. */
moments: string[];
arose_from_id: number | null;
verify_with: string;
expires_when: string;
}
export async function createRule(topicId: number, data: Partial<RuleWrite> & { title: string; statement: string }): Promise<Rule> {
export async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string; order_index?: number }): Promise<Rule> {
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
}
export async function updateRule(id: number, data: Partial<RuleWrite>): Promise<Rule> {
export async function updateRule(id: number, data: Partial<{ title: string; statement: string; why: string; how_to_apply: string; order_index: number }>): Promise<Rule> {
return apiPatch(`/api/rules/${id}`, data);
}
/** Give a rule a new home: a topic makes it global, a project makes it that
* project's. Keeps its id, history, areas and relations (milestone 414). */
export async function moveRule(
id: number, to: { topic_id: number } | { project_id: number },
): Promise<Rule> {
return apiPost(`/api/rules/${id}/move`, to);
}
/** Draw a typed edge from one rule to another. Idempotent. */
export async function relateRules(
fromRuleId: number,
data: { to_rule_id: number; kind: RuleRelationKind; note?: string },
): Promise<{ id: number }> {
return apiPost(`/api/rules/${fromRuleId}/relations`, data);
}
export async function unrelateRules(relationId: number): Promise<void> {
return apiDelete(`/api/rule-relations/${relationId}`);
}
/**
* One entry in a rule's edit history.
*
* Each entry holds the text the edit REPLACED, not the text it introduced —
* so the newest entry is what the rule said before its most recent change,
* and what that change produced is the rule as it stands now. Read the other
* way round, every diff comes out backwards.
*
* The listing form omits the long fields; open one to get them.
*/
export interface RuleVersion {
id: number;
rule_id: number;
/** Who made the edit. Null when that account has since been deleted. */
user_id: number | null;
title: string;
created_at: string;
statement?: string;
why?: string;
how_to_apply?: string;
when_to_apply?: string;
verify_with?: string;
expires_when?: string;
}
export async function listRuleVersions(ruleId: number): Promise<RuleVersion[]> {
const data = await apiGet<{ versions: RuleVersion[] }>(
`/api/rules/${ruleId}/versions`,
);
return data.versions;
}
export async function getRuleVersion(
ruleId: number, versionId: number,
): Promise<RuleVersion> {
return apiGet<RuleVersion>(`/api/rules/${ruleId}/versions/${versionId}`);
}
// No restore FOR A RULE, deliberately (milestone 323). Putting an old wording
// back goes through updateRule, which snapshots what it replaces — so the
// undo stays visible in the history like any other edit.
//
// A preference is the exception and the server refuses anything else (409).
// 323's reasoning is that a rewrite is the operator's own decision; a
// preference's rewrite is the agent's, made mid-work without asking, so
// putting it back is a veto rather than an undo — and a veto that costs more
// than shrugging is not really supervision. Nothing is erased either way:
// the restore snapshots too, so the history GAINS the revert.
export async function restoreRuleVersion(
ruleId: number, versionId: number,
): Promise<Rule> {
return apiPost<Rule>(`/api/rules/${ruleId}/versions/${versionId}/restore`, {});
}
/**
* One preference that has been rewritten: what it said, what it says now, and
* what taught the change.
*
* Both texts ride along so the list can show the diff without a follow-up call
* per row — a listing that needs N round-trips to say what it means is one
* nobody scrolls, which would leave the drift as unsupervised as before.
*/
export interface PreferenceDrift {
rule: RuleHeader;
/** What it said BEFORE the latest rewrite. */
previous: {
id: number;
created_at: string | null;
title: string;
statement: string;
when_to_apply: string;
};
current: { title: string; statement: string; when_to_apply: string };
/**
* The record named by `arose_from_id` — what the change was learned from.
* Absent when the preference carries none, which is every one written
* before that field was required.
*/
taught_by?: { id: number; title: string };
}
export async function listPreferenceDrift(
limit?: number,
): Promise<PreferenceDrift[]> {
const qs = limit ? `?limit=${limit}` : "";
const data = await apiGet<{ drift: PreferenceDrift[] }>(`/api/rules/drift${qs}`);
return data.drift;
}
export async function deleteRule(id: number): Promise<void> {
return apiDelete(`/api/rules/${id}`);
}
// ── A project's rules ──────────────────────────────────────────────
// ── Subscriptions ──────────────────────────────────────────────────
export async function subscribeProject(projectId: number, rulebookId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/rulebook-subscriptions`, { rulebook_id: rulebookId });
}
export async function unsubscribeProject(projectId: number, rulebookId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/rulebook-subscriptions/${rulebookId}`);
}
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
return apiGet(`/api/projects/${projectId}/rules`);
@@ -358,61 +159,25 @@ export async function getProjectApplicableRules(projectId: number): Promise<Appl
export async function createProjectRule(
projectId: number,
data: Partial<RuleWrite> & { statement: string },
data: { statement: string; title?: string; why?: string; how_to_apply?: string },
): Promise<Rule> {
return apiPost(`/api/projects/${projectId}/rules`, data);
}
// ── Suppressions ───────────────────────────────────────────────────
/**
* One row of the staleness sweep. Unlike RuleHeader this carries the CHECK
* in full — the reader is about to go and run it, so the text is the point
* of the payload rather than the bloat a listing avoids.
*/
export interface RuleVerificationRow {
id: number;
title: string;
statement: string;
topic_id: number | null;
project_id: number | null;
when_to_apply: string;
verify_with: string;
expires_when: string;
/** A date (YYYY-MM-DD), or the literal "never". */
last_verified: string | null;
/** Null when never verified — "never" is not zero days ago. */
days_since_verified: number | null;
export async function suppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/suppressions/rules/${ruleId}`, {});
}
/**
* Rules asserting a fact that may have gone false, oldest verification
* first, never-checked at the top. Rules without a check never appear:
* they are decisions, and there is nothing to go and check.
*
* Not filterable by project — a project is bound by its own rules and by
* every global rule, and a filter that dropped the global ones would
* under-report.
*/
export async function listRulesDueForVerification(opts: {
olderThanDays?: number;
neverOnly?: boolean;
} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> {
const q = new URLSearchParams();
if (opts.olderThanDays) q.set("older_than_days", String(opts.olderThanDays));
if (opts.neverOnly) q.set("never_only", "true");
const qs = q.toString();
return apiGet(`/api/rules-due-for-verification${qs ? `?${qs}` : ""}`);
export async function unsuppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/rules/${ruleId}`);
}
/**
* Record that a rule's check was RUN, and what it said.
*
* `stillTrue: false` writes nothing on purpose — a rule whose check failed
* is not in a recordable state, it is wrong — so it stays at the top of the
* sweep until someone corrects or retires it.
*/
export async function markRuleVerified(
id: number, stillTrue = true,
): Promise<Rule & { verified: boolean }> {
return apiPost(`/api/rules/${id}/verify`, { still_true: stillTrue });
export async function suppressTopicForProject(projectId: number, topicId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/suppressions/topics/${topicId}`, {});
}
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
}
+9 -14
View File
@@ -1,5 +1,3 @@
import type { RecordUsage } from "@/types/usage";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
/** One canonical location of a reusable thing. A snippet that unifies several
@@ -30,9 +28,6 @@ export interface SnippetFields {
* no `locations`/`tags` predates that attribution and cannot be un-merged. */
merged_from: { id: number; locations?: SnippetLocation[]; tags?: string[] }[];
code: string;
/** Free text that is not the situation — why, history, caveats (#4378).
* Kept out of `when_to_use`, which the snippet is ranked on. */
notes: string;
}
/** A full snippet record: the note dict plus the parsed `snippet` sub-object,
@@ -55,11 +50,15 @@ export interface Snippet {
owner?: string | null;
}
/** Kept as a name because every consumer here says "snippet usage" — but it IS
* the shared shape, since rules answer the same question off their own table
* (milestone 333). The reasoning lives on `RecordUsage`; duplicating the four
* fields here is how the two drift. */
export type SnippetUsage = RecordUsage;
/** How often a record was put in front of an agent versus actually opened.
* A high `surfaced_count` with `pull_count: 0` is dead weight — it occupies a
* slot in every future auto-inject menu while never being used. */
export interface SnippetUsage {
surfaced_count: number;
pull_count: number;
last_surfaced_at: string | null;
last_pulled_at: string | null;
}
/** Result of the last drift check — does the recorded location and code still
* match source? The check runs agent-side (Scribe has no checkout); this is the
@@ -94,9 +93,6 @@ export interface SnippetListItem {
* hit can be flagged as being in a DIFFERENT language than the file being
* written, which is a shape to adapt rather than code to paste. */
language?: string;
/** When to reach for it, projected from the `data` mirror. The title is the
* name alone since milestone 427, so this is where the situation lives. */
when_to_use?: string;
/** Always present from the backend, zero-filled for records with no events. */
usage?: SnippetUsage;
/** Present on the detail record; the list feed carries it when a check has
@@ -113,7 +109,6 @@ export interface SnippetInput {
language?: string;
signature?: string;
when_to_use?: string;
notes?: string;
locations?: SnippetLocation[];
tags?: string[];
project_id?: number | null;
+2 -42
View File
@@ -1,34 +1,14 @@
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
import type { CanonicalMatch } from "@/api/canonicalSystems";
import type { RecordUsage } from "@/types/usage";
export interface System {
id: number;
project_id: number;
name: string;
/**
* The global area this System is an instance of, or null. Null is a valid
* resting state — a project-specific area should stay unmapped.
*/
canonical_id: number | null;
description: string;
color: string | null;
status: "active" | "archived";
order_index: number;
/**
* The files that are this area, as globs relative to the repo root: `*`
* within one directory, `**` across any depth, a plain directory covering
* everything under it. Empty means the area has not named its files.
*/
path_patterns: string[];
open_issue_count: number;
/**
* How often this area's rulings were shown to a session because its files
* were touched (`surfaced_count`), and how often it was opened (#4769).
* Present on the list and detail reads; optional because a System returned
* by a create or update carries none.
*/
usage?: RecordUsage;
created_at: string | null;
updated_at: string | null;
}
@@ -38,29 +18,10 @@ export async function listSystems(projectId: number): Promise<System[]> {
return data.systems;
}
/**
* A created System, plus the catalog's answer about its name. An `exact`
* catalog hit is applied by the server and arrives as a populated
* `canonical_id`; an `overlap` is only OFFERED, and comes back here for the
* caller to accept or ignore.
*
* A same-named System in this project is a 409 ApiError carrying
* `{duplicate, existing_id}` — the same gate the MCP door enforces (#2482).
*/
export interface CreatedSystem extends System {
canonical_suggestion?: CanonicalMatch;
}
export async function createSystem(
projectId: number,
data: {
name: string;
description?: string;
color?: string;
canonical_id?: number;
path_patterns?: string[];
},
): Promise<CreatedSystem> {
data: { name: string; description?: string; color?: string },
): Promise<System> {
return apiPost(`/api/projects/${projectId}/systems`, data);
}
@@ -73,7 +34,6 @@ export async function updateSystem(
color: string | null;
status: "active" | "archived";
order_index: number;
path_patterns: string[];
}>,
): Promise<System> {
return apiPatch(`/api/projects/${projectId}/systems/${systemId}`, data);
-44
View File
@@ -1,44 +0,0 @@
import { apiGet } from "./client";
/**
* What `/api/version` answers — the client's half of `build_version_payload`
* (`src/scribe/routes/api.py`), which is where the reasoning for the shape is
* written down.
*
* EVERY FIELD BUT `version` IS OPTIONAL, and an absent one means "this build
* does not know", not "empty". A local build has no ordering key and no
* channel, and the server says so by omitting the keys rather than sending
* `""` — emitting a placeholder would let it claim a position in an update
* order it is not part of.
*
* So a renderer must read ABSENCE, never falsiness. `build` is a number and
* `0` is a legitimate ordering key, so `v.build || "unknown"` would report a
* real value as unknown; `v.build ?? "unknown"` is the correct form.
*/
export interface VersionPayload {
/** The NAME — `YYYY.MM.DD.HHMM` from commit time. Answers "is this the same code?" */
version: string;
/** The ORDERING KEY — minutes since 2020-01-01, from build time. Absent on a local build. */
build?: number;
/** `dev` / `main` / a tag. Its own field, never folded into the name. */
channel?: string;
/** The commit the artifact was published under, so its claim can be checked against the registry. */
commit?: string;
}
/**
* SHORTER than the client's 30s default, deliberately.
*
* This readout answers "what is running?" during an incident, which is exactly
* when the server may be the thing that is unwell — and it is one static field
* off a route that does no work, so a healthy instance answers it immediately.
* Waiting the full default before saying so would leave a person staring at
* "still loading" for half a minute in the moment they are trying to find out
* whether the instance is alive at all. Eight seconds clears a slow-but-alive
* instance and tells them something quickly when it is not.
*/
const VERSION_TIMEOUT_MS = 8000;
export function fetchVersion(): Promise<VersionPayload> {
return apiGet<VersionPayload>("/api/version", { timeoutMs: VERSION_TIMEOUT_MS });
}
-115
View File
@@ -1,115 +0,0 @@
/* ── Auth surface (Login / Register / RegisterInvite / ForgotPassword / ResetPassword) ──
The five auth views used to carry byte-identical copies of these rules in
their scoped blocks (2026-08 shape audit). Loaded per view with
<style src="@/assets/auth-shared.css" />, like editor-shared.css; the form
rules are scoped under .auth-card so nothing leaks into the app's other
.field/.input usages. Per-view one-offs (Login's .divider/.forgot-link)
stay in the view. */
.auth-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 2rem;
}
.auth-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.auth-card h1 {
margin: 0;
text-align: center;
}
.auth-hint {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin-bottom: 1rem;
}
.auth-hint a {
color: var(--fs-accent);
}
/* A centred status paragraph block: registration closed, invalid/expired
token, "check your inbox". One rule — the views used to name it
.closed-msg / .error-block / .success-msg with identical bodies. */
.auth-note {
text-align: center;
color: var(--fs-text-secondary);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.auth-note p {
margin: 0.5rem 0;
}
.auth-loading {
text-align: center;
color: var(--fs-text-tertiary);
font-size: 0.95rem;
padding: 1rem 0;
}
.auth-card .field {
margin-bottom: 1rem;
}
.auth-card .field label {
display: block;
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 0.35rem;
}
.auth-card .input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
font-size: 0.95rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
box-sizing: border-box;
}
.auth-card .input:focus {
outline: none;
border-color: var(--fs-accent);
}
.auth-card .input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-card .input-error,
.auth-card .input-error:focus {
border-color: var(--fs-error);
}
.auth-card .field-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
}
.auth-card .error-hint {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--fs-error);
}
.auth-card .error-msg {
color: var(--fs-error);
font-size: 0.9rem;
margin: 0 0 0.75rem;
}
.auth-footer {
text-align: center;
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 1rem 0 0;
}
.auth-footer a {
color: var(--fs-accent);
}
+17 -203
View File
@@ -36,8 +36,7 @@
.btn-secondary,
.btn-ghost,
.btn-danger,
.btn-danger-outline,
.btn-cta {
.btn-danger-outline {
padding: var(--fs-space-2) var(--fs-space-4); /* 8px 16px */
border: none;
border-radius: var(--fs-radius-md); /* 8px — the system's button radius */
@@ -47,14 +46,6 @@
line-height: var(--fs-leading-body);
white-space: nowrap;
cursor: pointer;
/* So a button carrying an icon centres it against the label without each
caller re-inventing the flex row — the shape they all reached for
separately, and the reason icon buttons sat a pixel or two off. */
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--fs-space-2);
text-decoration: none;
transition: background var(--fs-dur-fast) var(--fs-ease),
border-color var(--fs-dur-fast) var(--fs-ease),
color var(--fs-dur-fast) var(--fs-ease);
@@ -67,8 +58,7 @@
.btn-secondary:disabled,
.btn-ghost:disabled,
.btn-danger:disabled,
.btn-danger-outline:disabled,
.btn-cta:disabled {
.btn-danger-outline:disabled {
opacity: var(--fs-disabled-opacity);
cursor: not-allowed;
}
@@ -77,8 +67,7 @@
.btn-secondary:focus-visible,
.btn-ghost:focus-visible,
.btn-danger:focus-visible,
.btn-danger-outline:focus-visible,
.btn-cta:focus-visible {
.btn-danger-outline:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring);
}
@@ -89,19 +78,19 @@
* are universal across the family so a Save button looks identical in every
* app — the accent is identity, not action. */
.btn-primary {
background: var(--fs-action-primary);
background: var(--color-action-primary);
color: var(--fs-text-on-action);
}
.btn-primary:not(:disabled):hover {
background: var(--fs-action-primary-hover);
background: var(--color-action-primary-hover);
}
.btn-secondary {
background: var(--fs-action-secondary);
background: var(--color-action-secondary);
color: var(--fs-text-on-action);
}
.btn-secondary:not(:disabled):hover {
background: var(--fs-action-secondary-hover);
background: var(--color-action-secondary-hover);
}
/* Ghost is an OUTLINE, which is why its border and the tertiary action colour
@@ -112,21 +101,21 @@
.btn-ghost {
background: none;
border: var(--fs-border);
color: var(--fs-text-primary);
color: var(--color-text);
}
.btn-ghost:not(:disabled):hover {
border: var(--fs-border-hover);
background: var(--fs-surface-hover);
background: var(--color-hover);
}
/* Destructive is NOT the error colour: an error is a failure that happened, a
* destructive action is one about to happen. Pair with an icon. */
.btn-danger {
background: var(--fs-action-destructive);
background: var(--color-action-destructive);
color: var(--fs-text-on-action);
}
.btn-danger:not(:disabled):hover {
background: var(--fs-action-destructive-hover);
background: var(--color-action-destructive-hover);
}
/* A bare text button: no fill, no border. The most common shape in the dense
@@ -136,7 +125,7 @@
.btn-text {
background: none;
border: none;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
padding: var(--fs-space-1) var(--fs-space-2);
font-family: var(--fs-font-body);
font-size: var(--fs-size-tiny);
@@ -144,7 +133,7 @@
cursor: pointer;
transition: color var(--fs-dur-fast) var(--fs-ease);
}
.btn-text:not(:disabled):hover { color: var(--fs-text-primary); }
.btn-text:not(:disabled):hover { color: var(--color-text); }
.btn-text:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
.btn-text:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
@@ -153,33 +142,14 @@
* a one-off: it is what a delete looks like when it must not shout. */
.btn-danger-outline {
background: none;
border: 1px solid var(--fs-action-destructive);
color: var(--fs-action-destructive);
border: 1px solid var(--color-action-destructive);
color: var(--color-action-destructive);
}
.btn-danger-outline:not(:disabled):hover {
background: var(--fs-action-destructive);
background: var(--color-action-destructive);
color: var(--fs-text-on-action);
}
/* The one place the accent is allowed on a button: a deliberate brand moment,
* never an ordinary action. The system carries `--fs-gradient-cta` and
* `--fs-glow-cta` for exactly this and nothing else was using them.
*
* It exists because ProjectView's Workspace link WAS this button, defined in a
* scoped block that the migration deleted — leaving a `:hover` rule with no
* base and a link that rendered as raw browser blue. A variant living in one
* view is a variant waiting to be deleted by someone tidying another; this is
* the shared home so the next sweep can't strand it. */
.btn-cta {
background: var(--fs-gradient-cta);
color: var(--fs-text-on-action);
box-shadow: var(--fs-glow-cta);
text-decoration: none;
}
.btn-cta:not(:disabled):hover {
box-shadow: var(--fs-glow-cta-hover);
}
/* --- size modifiers ------------------------------------------------------
*
* THREE sizes, because the app genuinely has three. Measured across the ~100
@@ -214,166 +184,10 @@
/* Full width, for a form's single submitting action — the auth screens. Width
* is orthogonal to size, so it composes: `btn-primary btn-block`. */
.btn-block {
display: flex; /* not `block` — the shared shape centres with flex */
display: block;
width: 100%;
padding: var(--fs-space-3) var(--fs-space-4); /* 12px 16px — a touch taller,
because a full-width button
is the page's main action */
font-size: var(--fs-size-body-sm);
}
/* ── Modal ─────────────────────────────────────────────────────────────────
The one overlay/card/button shape for every in-app dialog (ConfirmDialog,
the create-project / merge-snippet / systems dialogs, the editors' confirm
prompts). Global on purpose: ConfirmDialog teleports to <body> and has no
styles of its own, so these must be loaded with the app, not with whichever
view happens to be open. Views add only their own overrides (a wider card,
a form layout). Destructive = action-destructive per the Hybrid rule. */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--fs-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title {
margin: 0 0 0.75rem;
font-size: 1.05rem;
}
.modal-message {
font-size: 0.9rem;
color: var(--fs-text-secondary);
margin: 0 0 1.25rem;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-radius: var(--fs-radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--fs-surface-page);
}
.modal-btn-primary {
background: var(--fs-action-primary);
border-color: var(--fs-action-primary);
color: var(--fs-text-on-action);
}
.modal-btn-primary:hover:not(:disabled) {
background: var(--fs-action-primary-hover);
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.modal-btn-danger {
background: var(--fs-action-destructive);
border-color: var(--fs-action-destructive);
color: var(--fs-text-on-action);
}
.modal-btn-danger:hover {
background: var(--fs-action-destructive-hover);
border-color: var(--fs-action-destructive-hover);
}
/* ── Page container ─────────────────────────────────────────────────────────
The one wrapper a top-level view sits in: page width from the layout
tokens, centred, clipped horizontally so a wide child (a kanban, a table)
scrolls inside itself instead of the page. ProjectListView, ProjectView and
SnippetListView each carried this rule under their own name until #2903
(milestone 299). */
.page-container {
max-width: var(--fs-layout-page-max);
margin: 2rem auto;
padding: 0 var(--fs-layout-page-pad);
overflow-x: clip;
}
/* ── Form input (fs-surfaces, snippet #2336) ────────────────────────────────
Inputs sit DARKER than the page they're on — an inset well rather than a
raised panel; that inversion is what makes a field read as writable. The
design system's recipe, verbatim; width/box-sizing stay the caller's
(an inline select and a full-width textarea differ there). Three scoped
copies of an older input recipe were folded into this in #2903. */
.fs-input {
background: var(--fs-surface-page);
border: var(--fs-border);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-2) var(--fs-space-3); /* 8px 12px */
color: var(--fs-text-primary);
font-family: var(--fs-font-body);
font-size: var(--fs-size-body);
transition: box-shadow var(--fs-dur-fast) var(--fs-ease);
}
.fs-input::placeholder { color: var(--fs-text-tertiary); }
.fs-input:focus { outline: none; box-shadow: var(--fs-focus-ring); }
.fs-input:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
/* Page scaffold + feedback text recipes (milestone 302, note 2917): name
families the consumer map showed to be one recipe living in many views.
A view keeps only its deviation as a scoped remainder/override. */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.page-header h1 { margin: 0; }
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
.state-msg { color: var(--fs-text-tertiary); font-size: 0.9rem; }
.empty-msg { color: var(--fs-text-tertiary); font-size: 0.875rem; }
.empty-title { font-size: 1rem; font-weight: 500; color: var(--fs-text-secondary); margin: 0 0 0.35rem; }
.empty-sub { font-size: 0.85rem; color: var(--fs-text-tertiary); margin: 0 0 1rem; }
.required { color: var(--fs-error); }
.field-hint { margin: 0.3rem 0 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
/* --- usage badge ----------------------------------------------------------
"surfaced N×, opened M×" on a list row, for any record kind the retrieval
surfaces can choose: snippets and notes from note_usage_events, rules from
rule_usage_events. Promoted here from SnippetListView's scoped block when
the rule list needed the same chip (milestone 333 step 5) — a second scoped
copy is how the ninth duplicated CSS family starts (#3207).
Geometry and colour only. A view keeps its own spacing as a scoped
remainder, the way it does for every other recipe in this file. */
.usage-tag {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
color: var(--fs-text-tertiary-fg);
}
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
than the danger one, because the record isn't broken, just unearned. */
.usage-tag.usage-dead {
background: color-mix(in srgb, var(--fs-warning) 18%, transparent);
color: var(--fs-warning-fg);
}
-50
View File
@@ -1,50 +0,0 @@
/* The near-duplicate report, shared by KnowledgeView (notes/tasks) and
SnippetListView (snippets) so the two reports read as one feature. Load
with <style src="@/assets/dup-report.css" /> beside the view's scoped
block; the view keeps only its own extras (.dup-claimed, .dup-action).
Promoted from two identical scoped copies in #2903 (milestone 299). */
.dup-panel {
margin-bottom: 1.25rem;
padding: 0.85rem 1rem;
border: 1px solid var(--fs-border-color);
border-radius: 8px;
background: var(--fs-surface-hover);
}
.dup-empty,
.dup-head {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--fs-text-tertiary);
}
.dup-empty { margin-bottom: 0; }
.dup-group {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding: 0.5rem 0;
border-top: 1px solid var(--fs-border-color);
}
.dup-members {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
flex: 1 1 20rem;
min-width: 0;
}
.dup-member {
font-size: 0.8rem;
padding: 0.1rem 0.45rem;
border-radius: 4px;
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
color: var(--fs-text-primary);
text-decoration: none;
overflow-wrap: anywhere;
}
.dup-member:hover { background: var(--fs-surface-hover); }
.dup-score {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
+252 -87
View File
@@ -13,7 +13,19 @@
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.5rem 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.editor-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.editor-main {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 0.75rem 1.5rem 1.5rem;
}
/* ── Toolbar & inputs ── */
@@ -29,36 +41,36 @@
with a Trash icon at the call site to reinforce intent. */
.title-input:focus {
outline: none;
border-bottom-color: var(--fs-accent);
border-bottom-color: var(--color-primary);
}
.title-input::placeholder {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-weight: 400;
}
.editor-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.tab {
padding: 0.45rem 1rem;
border: none;
background: none;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
cursor: pointer;
font-size: 0.9rem;
border-bottom: 2px solid transparent;
}
.tab.active {
color: var(--fs-accent);
border-bottom-color: var(--fs-accent);
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.preview-pane {
padding: 0.75rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
min-height: 200px;
background: var(--fs-surface-raised);
background: var(--color-bg-card);
}
/* ── Tag suggestions ── */
@@ -66,44 +78,133 @@
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem;
gap: 0.4rem;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.2rem 0.55rem;
border: 1px solid var(--fs-accent);
border: 1px solid var(--color-primary);
border-radius: 999px;
background: transparent;
color: var(--fs-accent);
color: var(--color-primary);
font-size: 0.8rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.tag-pill:hover:not(:disabled) {
background: var(--fs-accent);
background: var(--color-primary);
color: var(--fs-text-on-action);
}
.tag-pill.applied {
background: var(--fs-success);
border-color: var(--fs-success);
background: var(--color-success, #2ecc71);
border-color: var(--color-success, #2ecc71);
color: var(--fs-text-on-action);
cursor: default;
}
.tag-check {
font-size: 0.7rem;
}
/* ── Assist panel ── */
.assist-panel {
width: 320px;
flex-shrink: 0;
border-left: 1px solid var(--color-border);
background: var(--color-bg-secondary);
display: flex;
flex-direction: column;
overflow: hidden;
}
.assist-panel-header {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 0.9rem;
border-bottom: 1px solid var(--color-border);
}
.assist-panel-title {
flex: 1;
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.assist-panel-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0.75rem 0.9rem 1rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
/* Section list */
.assist-sections-label {
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
margin-bottom: 0.2rem;
}
.assist-sections {
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
max-height: 200px;
overflow-y: auto;
flex-shrink: 0;
}
.assist-section-item {
padding: 0.35rem 0.7rem;
cursor: pointer;
font-size: 0.82rem;
border-left: 3px solid transparent;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.assist-section-item:hover {
background: var(--color-bg-secondary);
}
.assist-section-item.selected {
border-left-color: var(--color-primary);
background: var(--color-bg-secondary);
font-weight: 500;
}
.assist-empty,
.assist-hint {
padding: 0.6rem 0.7rem;
font-size: 0.82rem;
color: var(--color-text-muted);
}
.assist-target-preview {
font-size: 0.8rem;
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assist-target-preview em {
font-style: normal;
color: var(--color-text);
}
.assist-instruction {
width: 100%;
padding: 0.5rem 0.65rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
font-size: 0.88rem;
font-family: inherit;
resize: vertical;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
min-height: 3.5rem;
}
@@ -112,27 +213,64 @@
gap: 0.5rem;
}
/* Streaming */
.assist-streaming-label {
font-size: 0.8rem;
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.assist-preview-box {
padding: 0.65rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
font-size: 0.9rem;
max-height: 300px;
overflow-y: auto;
}
.typing-indicator {
color: var(--color-text-muted);
font-size: 0.75rem;
letter-spacing: 0.15em;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
/* Active hint shown in the panel while output is inline */
.assist-active-hint {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
text-align: center;
}
/* Error */
.assist-error {
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
border: 1px solid var(--fs-error);
border-radius: var(--fs-radius-sm);
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
border: 1px solid var(--color-danger);
border-radius: var(--radius-sm);
font-size: 0.85rem;
color: var(--fs-error-fg);
color: var(--color-danger);
}
/* Review / diff */
.assist-review-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text-secondary);
}
.diff-view {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
font-size: 0.82rem;
font-family: monospace;
max-height: 340px;
@@ -146,15 +284,15 @@
line-height: 1.5;
}
.diff-delete {
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
color: var(--fs-error-fg);
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
color: var(--color-danger);
}
.diff-insert {
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
color: var(--fs-success-fg);
background: color-mix(in srgb, var(--color-success) 12%, transparent);
color: var(--color-success);
}
.diff-equal {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.diff-marker {
flex-shrink: 0;
@@ -170,7 +308,7 @@
}
.diff-empty {
padding: 0.5rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.82rem;
}
.assist-actions {
@@ -178,16 +316,63 @@
gap: 0.5rem;
}
/* ── Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--color-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border-radius: var(--radius-md);
padding: 1.5rem;
max-width: 400px;
width: 90%;
box-shadow: 0 8px 32px var(--color-shadow);
}
.modal-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.modal-message {
margin: 0 0 1.25rem;
color: var(--color-text-secondary);
font-size: 0.95rem;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.modal-btn {
padding: 0.45rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
font-size: 0.9rem;
}
.modal-btn-danger {
background: var(--color-danger);
color: var(--fs-text-on-action);
border-color: var(--color-danger);
}
/* ── Floating inline assist button (teleported to body) ── */
.inline-assist-btn {
position: fixed;
z-index: 100;
transform: translateX(-50%);
padding: 0.3rem 0.75rem;
background: var(--fs-action-primary);
background: var(--color-action-primary);
color: var(--fs-text-on-action);
border: none;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
box-shadow: 0 2px 8px var(--color-shadow);
@@ -199,12 +384,12 @@
display: none;
width: 100%;
padding: 0.6rem 1rem;
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
border: none;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
font-weight: 500;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
cursor: pointer;
text-align: left;
font-family: inherit;
@@ -223,7 +408,7 @@
.sb-label {
font-size: 0.78rem;
font-weight: 500;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
@@ -231,10 +416,10 @@
.sb-input {
width: 100%;
padding: 0.35rem 0.5rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
@@ -242,11 +427,11 @@
.sb-select:focus,
.sb-input:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
.sb-divider {
height: 1px;
background: var(--fs-border-color);
background: var(--color-border);
margin: 0.15rem 0;
}
@media (max-width: 720px) {
@@ -260,9 +445,22 @@
/* ── Mobile ── */
@media (max-width: 768px) {
.editor-body {
flex-direction: column;
}
.assist-panel {
width: auto;
flex: 0 0 45%;
border-left: none;
border-top: 1px solid var(--color-border);
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
.editor-header {
padding: 0.75rem 1rem 0.5rem;
}
.editor-main {
padding: 0.5rem 1rem 1rem;
}
}
/* ---------------------------------------------------------------------------
@@ -282,14 +480,14 @@
.btn-accept,
.btn-generate,
.btn-save {
background: var(--fs-action-primary);
background: var(--color-action-primary);
color: var(--fs-text-on-action);
border: none;
}
.btn-accept:not(:disabled):hover,
.btn-generate:not(:disabled):hover,
.btn-save:not(:disabled):hover {
background: var(--fs-action-primary-hover);
background: var(--color-action-primary-hover);
}
.btn-back,
@@ -299,7 +497,7 @@
.btn-suggest-tags {
background: none;
border: var(--fs-border);
color: var(--fs-text-primary);
color: var(--color-text);
}
.btn-back:not(:disabled):hover,
.btn-clear:not(:disabled):hover,
@@ -307,16 +505,16 @@
.btn-proofread:not(:disabled):hover,
.btn-suggest-tags:not(:disabled):hover {
border: var(--fs-border-hover);
background: var(--fs-surface-hover);
background: var(--color-hover);
}
.btn-delete {
background: var(--fs-action-destructive);
background: var(--color-action-destructive);
color: var(--fs-text-on-action);
border: none;
}
.btn-delete:not(:disabled):hover {
background: var(--fs-action-destructive-hover);
background: var(--color-action-destructive-hover);
}
/* Shared geometry for every alias above. */
@@ -343,12 +541,12 @@
.btn-dismiss-tags {
background: none;
border: none;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
padding: 2px var(--fs-space-1);
font-size: var(--fs-size-tiny);
line-height: 1;
}
.btn-dismiss-tags:hover { color: var(--fs-text-primary); }
.btn-dismiss-tags:hover { color: var(--color-text); }
.btn-accept:disabled, .btn-generate:disabled, .btn-save:disabled,
.btn-back:disabled, .btn-clear:disabled, .btn-reject:disabled,
@@ -357,36 +555,3 @@
opacity: var(--fs-disabled-opacity);
cursor: not-allowed;
}
/* Shared by NoteEditorView and TaskEditorView — both carried identical scoped
copies of these until #2903 (milestone 299); one source here. */
.body-tabs-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
}
.body-editor-wrap {
min-height: 200px;
}
.stream-preview {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.75rem;
background: var(--fs-surface-raised);
min-height: 200px;
}
.main-diff {
flex: 1;
min-height: 0;
}
.assist-section-title {
font-size: 0.78rem;
font-weight: 500;
color: var(--fs-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
-26
View File
@@ -1,26 +0,0 @@
/* Moments (milestone 458): the pieces the rule editor's picker and the
Settings section share, so a moment's name and a chip's remove control look
the same wherever a person meets them. Loaded unscoped by both. */
/* The name as a session reads it in a delivered line — shown as written. */
.moment-name {
font-family: var(--fs-font-mono);
color: var(--fs-text-primary);
}
/* The × on a removable chip: quiet until hovered, and keyboard-reachable. */
.chip-remove {
background: none;
border: none;
cursor: pointer;
padding: 0;
font: inherit;
color: var(--fs-text-tertiary);
}
.chip-remove:hover:not(:disabled) { color: var(--fs-text-primary); }
.chip-remove:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
.chip-remove:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring);
border-radius: var(--fs-radius-sm);
}
+24 -24
View File
@@ -41,8 +41,8 @@
}
.prose pre {
background: var(--fs-surface-code);
border: 1px solid var(--fs-border-color);
background: var(--color-code-bg);
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.75rem;
overflow-x: auto;
@@ -57,7 +57,7 @@
}
.prose code {
background: var(--fs-surface-code-inline);
background: var(--color-code-inline-bg);
border-radius: 3px;
padding: 0.15rem 0.35rem;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
@@ -73,25 +73,25 @@
.prose th,
.prose td {
border: 1px solid var(--fs-border-color);
border: 1px solid var(--color-border);
padding: 0.4rem 0.6rem;
text-align: left;
}
.prose thead th {
background: var(--fs-surface-raised);
background: var(--color-bg-secondary);
font-weight: 600;
}
.prose tbody tr:nth-child(even) {
background: var(--fs-table-stripe);
background: var(--color-table-stripe);
}
.prose blockquote {
border-left: 3px solid var(--fs-border-color);
border-left: 3px solid var(--color-border);
margin: 0 0 0.6rem;
padding: 0.25rem 0 0.25rem 0.75rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
}
.prose blockquote p:last-child {
@@ -100,7 +100,7 @@
.prose hr {
border: none;
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border);
margin: 1rem 0;
}
@@ -110,7 +110,7 @@
}
.prose a {
color: var(--fs-accent);
color: var(--color-primary);
text-decoration: none;
}
@@ -119,8 +119,8 @@
}
.prose .inline-tag {
color: var(--fs-accent);
background: var(--fs-accent-soft);
color: var(--color-tag-text);
background: var(--color-tag-bg);
padding: 0.1rem 0.35rem;
border-radius: 4px;
text-decoration: none;
@@ -133,8 +133,8 @@
}
.prose .wikilink {
color: var(--fs-wikilink);
background: var(--fs-accent-soft);
color: var(--color-wikilink);
background: var(--color-wikilink-bg);
padding: 0.1rem 0.35rem;
border-radius: 4px;
text-decoration: none;
@@ -168,7 +168,7 @@
.prose ul[data-type="taskList"] li > label input[type="checkbox"] {
cursor: pointer;
accent-color: var(--fs-accent);
accent-color: var(--color-primary);
width: 0.95em;
height: 0.95em;
margin: 0;
@@ -180,7 +180,7 @@
.prose ul[data-type="taskList"] li[data-checked="true"] > div {
text-decoration: line-through;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
/* Interactive checkboxes — marked output in the list-note viewer */
@@ -196,7 +196,7 @@
}
.prose--checklist li input[type="checkbox"] {
flex-shrink: 0;
accent-color: var(--fs-accent);
accent-color: var(--color-primary);
cursor: pointer;
width: 0.95em;
height: 0.95em;
@@ -205,7 +205,7 @@
.prose--checklist li:has(input[type="checkbox"]:checked) > p,
.prose--checklist li:has(input[type="checkbox"]:checked) {
text-decoration: line-through;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.prose--checklist li:has(input[type="checkbox"]:checked) input[type="checkbox"] {
text-decoration: none; /* don't strike through the checkbox itself */
@@ -219,7 +219,7 @@
}
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
color: var(--fs-text-tertiary);
color: var(--color-text-muted, var(--color-text-secondary));
content: attr(data-placeholder);
float: left;
height: 0;
@@ -227,12 +227,12 @@
}
.tiptap-wrapper {
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
}
.tiptap-wrapper:focus-within {
box-shadow: var(--fs-focus-ring);
box-shadow: var(--focus-ring, 0 0 0 2px var(--color-primary));
}
-55
View File
@@ -1,55 +0,0 @@
/* Shared by the rules panes (RulebookListPane, RuleListPane,
RulebookDetailPane, RuleSweepPane): the pane surface, its heading, and the
title chip. Counting them in this comment went stale the first time a
fourth was added, so it no longer does. Load with
<style src="@/assets/rules-shared.css" /> beside the component's own
scoped block; never restate these there (#2903, milestone 299). */
.pane {
background: var(--fs-surface-hover);
padding: 1rem;
overflow-y: auto;
}
.pane header h2 {
font-family: Fraunces, serif;
font-style: italic;
margin: 0 0 0.5rem 0;
}
.form-buttons { display: flex; gap: 0.5rem; }
/* A small marker beside a rule's title. Two of these appeared within one
milestone (tier, then verification) and were byte-identical; a third would
have drifted. The pane's italic serif title is inherited by anything inside
it, so the chip resets family and style explicitly. */
/* A rule with no trigger cannot be retrieved, and since milestone 394
retrieval is the only delivery — so this marks a rule that will never
reach a session. Warning rather than error: the rule is not broken, it is
unreachable, and the fix is one field away. */
.rule-chip-inert {
color: var(--fs-warning-fg);
background: color-mix(in srgb, var(--fs-warning) 12%, var(--fs-surface-raised));
}
.rule-chip {
margin-left: 0.4rem;
font-family: var(--fs-font-body);
font-style: normal;
font-size: 0.62rem;
color: var(--fs-text-secondary);
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-pill);
padding: 0.05rem 0.4rem;
vertical-align: middle;
}
/* A PREFERENCE, marked because force is the one thing a list of instructions
must not leave the reader to infer. A preference does not bind — ignoring it
costs consistency, not correctness — and it is the one kind the agent
rewrites on its own, so a row that renders identically to a rule teaches the
opposite of both facts.
The accent, not the warning colour: nothing is wrong with a preference. It
is a different KIND, and the marker says which. */
.rule-chip-preference {
color: var(--fs-accent);
background: var(--fs-accent-soft);
}
-137
View File
@@ -1,137 +0,0 @@
/* The sweep-row visual language (#3207).
*
* A "sweep" is a pane that lists records in the order they most need a human
* — the note verification sweep, the rule sweep, preference drift — as one
* raised row per record: an italic title that opens it, an age or date pushed
* right, a small grid of facts, and a row of plain buttons. It was written
* out four times, scoped, before this sheet existed.
*
* Imported UNSCOPED, once, from main.ts, so every
* class is prefixed `sweep-`: `.row-title`, `.lede`, `.age` and `.actions`
* also exist, scoped, in views that have nothing to do with sweeps, and an
* unprefixed global would leak into all of them. What a pane does
* differently stays in its own scoped block — spacing remainders, a `code`
* style — rather than growing a modifier here per pane.
*/
/* The pane itself: header, filters, rows and footnote stacked. */
.sweep { display: flex; flex-direction: column; gap: var(--fs-space-3); }
.sweep-lede {
margin: 0;
max-width: 62ch;
font-size: var(--fs-size-body-sm);
color: var(--fs-text-secondary);
line-height: var(--fs-leading-body);
}
.sweep-filters {
display: flex;
gap: var(--fs-space-5);
align-items: center;
flex-wrap: wrap;
}
.sweep-filter {
display: flex;
align-items: center;
gap: var(--fs-space-2);
font-size: 0.82rem;
color: var(--fs-text-secondary);
}
.sweep-filter input[type="checkbox"] { accent-color: var(--fs-accent); }
.sweep-state {
margin: 0;
font-size: var(--fs-size-body-sm);
color: var(--fs-text-secondary);
}
.sweep-state.empty { color: var(--fs-text-tertiary); }
.sweep-rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--fs-space-3);
}
.sweep-row {
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-3);
}
.sweep-row-head {
display: flex;
align-items: baseline;
gap: var(--fs-space-2);
flex-wrap: wrap;
}
.sweep-row-title {
background: none;
border: none;
padding: 0;
cursor: pointer;
font-family: Fraunces, serif;
font-style: italic;
font-size: 1.02rem;
color: var(--fs-text-primary);
text-align: left;
}
.sweep-row-title:hover { text-decoration: underline; }
/* The ORDER carries urgency — the top of a sweep is the thing that most needs
a look. No red/amber ramp: it would restate the ordering and force an
invented "stale after N days" threshold. "Never" is marked because it is
categorically DIFFERENT from a date, not a worse one. */
.sweep-age {
margin-left: auto;
font-size: 0.78rem;
color: var(--fs-text-secondary);
font-variant-numeric: tabular-nums;
}
.sweep-age.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
.sweep-check {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.15rem var(--fs-space-3);
margin: var(--fs-space-3) 0 0;
}
.sweep-check dt {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-text-tertiary);
}
.sweep-check dd {
margin: 0;
font-size: 0.82rem;
color: var(--fs-text-primary);
min-width: 0;
}
.sweep-actions {
display: flex;
gap: var(--fs-space-2);
margin-top: var(--fs-space-3);
}
.sweep-actions button {
cursor: pointer;
font: inherit;
font-size: 0.78rem;
background: var(--fs-surface-page);
color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
padding: 0.25rem 0.6rem;
}
.sweep-actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
.sweep-actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
.sweep-footnote {
margin: 0;
max-width: 62ch;
font-size: 0.78rem;
color: var(--fs-text-tertiary);
line-height: 1.45;
}
+124 -32
View File
@@ -12,13 +12,6 @@
file used to read, and it is deliberate: the light palette was never specified
by any rule, so it is recorded as a departure rather than as the default.
The -fg tokens are a badge's TEXT colour, added because the ladder used its
raw hue as text on a 12% tint of the same hue — measured 1.60-2.97:1 on the
dark palette against the kit's AA floor of 4.5. Each is the hue mixed toward
--fs-text-primary until it clears 4.5:1 worst-case over surface-raised and
surface-hover in BOTH modes. Mixing toward that token is what makes one
declaration cover both: it inverts, so the text follows the mode.
Only 12 tokens differ between modes. Everything else — spacing, type, motion,
radius, and every derived colour — is stated once, because a value built with
var() resolves where it is USED, not where it is written.
@@ -31,7 +24,6 @@
--fs-accent-faint: color-mix(in srgb, var(--fs-accent) 8%, transparent); /* The faintest accent wash */
--fs-accent-deep: color-mix(in srgb, var(--fs-accent) 70%, black); /* The accent, darkened */
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 22%, transparent); /* Heaviest accent tint */
--fs-accent-fg: color-mix(in srgb, var(--fs-accent) 45%, var(--fs-text-primary)); /* Accent TEXT on an accent tint */
--fs-gradient-cta: linear-gradient(135deg, var(--fs-accent), var(--fs-accent-deep));
--fs-glow-cta: 0 2px 10px color-mix(in srgb, var(--fs-accent) 35%, transparent);
--fs-glow-cta-hover: 0 4px 24px color-mix(in srgb, var(--fs-accent) 65%, transparent);
@@ -86,13 +78,10 @@
/* priority */
--fs-priority-low: var(--fs-info);
--fs-priority-low-bg: color-mix(in srgb, var(--fs-priority-low) 12%, transparent);
--fs-priority-low-fg: color-mix(in srgb, var(--fs-priority-low) 45%, var(--fs-text-primary)); /* Badge TEXT for low priority — the readable partner of the -bg tint */
--fs-priority-medium: var(--fs-warning);
--fs-priority-medium-bg: color-mix(in srgb, var(--fs-priority-medium) 12%, transparent);
--fs-priority-medium-fg: color-mix(in srgb, var(--fs-priority-medium) 55%, var(--fs-text-primary)); /* Badge TEXT for medium priority */
--fs-priority-high: var(--fs-error);
--fs-priority-high-bg: color-mix(in srgb, var(--fs-priority-high) 12%, transparent);
--fs-priority-high-fg: color-mix(in srgb, var(--fs-priority-high) 55%, var(--fs-text-primary)); /* Badge TEXT for high priority */
/* radius */
--fs-radius-sm: 4px; /* pills, tags, code spans */
@@ -103,11 +92,8 @@
/* semantic */
--fs-success: var(--fs-action-primary);
--fs-success-fg: color-mix(in srgb, var(--fs-success) 45%, var(--fs-text-primary)); /* Success TEXT on a success tint */
--fs-warning: #8B6F1E;
--fs-warning-fg: color-mix(in srgb, var(--fs-warning) 50%, var(--fs-text-primary)); /* Warning TEXT on a warning tint */
--fs-error: #C04A1F;
--fs-error-fg: color-mix(in srgb, var(--fs-error) 50%, var(--fs-text-primary)); /* Error TEXT on an error tint */
--fs-info: #3D5A6E;
--fs-destructive: #6B2118; /* irreversible — deliberately not the error colour */
@@ -130,16 +116,12 @@
/* status */
--fs-status-todo: var(--fs-border-color);
--fs-status-todo-bg: color-mix(in srgb, var(--fs-status-todo) 12%, transparent);
--fs-status-todo-fg: color-mix(in srgb, var(--fs-status-todo) 40%, var(--fs-text-primary)); /* Badge TEXT for a not-started task */
--fs-status-in-progress: var(--fs-accent);
--fs-status-in-progress-bg: color-mix(in srgb, var(--fs-status-in-progress) 12%, transparent);
--fs-status-in-progress-fg: color-mix(in srgb, var(--fs-status-in-progress) 45%, var(--fs-text-primary)); /* Badge TEXT for a task underway */
--fs-status-done: var(--fs-success);
--fs-status-done-bg: color-mix(in srgb, var(--fs-status-done) 12%, transparent);
--fs-status-done-fg: color-mix(in srgb, var(--fs-status-done) 50%, var(--fs-text-primary)); /* Badge TEXT for a completed task */
--fs-overdue: var(--fs-error);
--fs-status-cancelled: var(--fs-text-tertiary); /* set aside, not failed */
--fs-status-cancelled-fg: color-mix(in srgb, var(--fs-status-cancelled) 60%, var(--fs-text-primary)); /* Badge TEXT for a cancelled task */
/* surface */
--fs-surface-page: #14171A; /* page bg, deepest surface */
@@ -152,9 +134,7 @@
/* text */
--fs-text-primary: #E8E4D8; /* body, headings, labels — inverts by mode */
--fs-text-secondary: #C2BFB4;
--fs-text-secondary-fg: color-mix(in srgb, var(--fs-text-secondary) 90%, var(--fs-text-primary)); /* Secondary TEXT on a secondary tint (barely moves; no exceptions) */
--fs-text-tertiary: #9C9A92;
--fs-text-tertiary-fg: color-mix(in srgb, var(--fs-text-tertiary) 55%, var(--fs-text-primary)); /* Tertiary TEXT on a tertiary tint */
--fs-text-on-action: #E8E4D8; /* text on a filled colour — NOT mode-dependent */
/* type */
@@ -208,21 +188,126 @@
*/
/* ==========================================================================
The compatibility-alias block that lived here is GONE (#2533). It let ~55
components keep their historical --color-* names while theme.css was
repointed at the design system; the rename sweep it promised ran on
2026-08-08 and every component now references --fs-* directly. Do not
reintroduce app-local alias names — the design system's tokens are the
vocabulary, and check_snippets_against_design_system can only see through
names the system actually declares.
COMPATIBILITY ALIASES — the app's historical names, pointing at the system.
These exist so ~55 components keep working while they migrate to --fs-*
one at a time. Every one is a plain var() reference, which is what lets this
block be declared ONCE: when [data-theme="light"] moves --fs-surface-page,
--color-bg follows, because the alias resolves at use time.
That is why this file lost 48 of its 60 dark-mode overrides — they were all
restating relationships the aliases now express directly.
Removing this block is a rename sweep across the components, tracked
separately. Nothing new should reference a --color-* name.
========================================================================== */
:root {
/* A VALUE, not an alias — the one survivor of the alias block. The design
system has no shadow-colour token yet, so this is a recorded gap: when a
second app needs it, promote it to an --fs-* token in the system and
regenerate, rather than copying this line. */
/* surfaces */
--color-bg: var(--fs-surface-page);
--color-bg-secondary: var(--fs-surface-raised);
--color-bg-card: var(--fs-surface-raised);
--color-surface: var(--fs-surface-hover);
--color-code-bg: var(--fs-surface-code);
--color-code-inline-bg: var(--fs-surface-code-inline);
--color-table-stripe: var(--fs-table-stripe);
--color-overlay: var(--fs-overlay);
/* text */
--color-text: var(--fs-text-primary);
--color-text-secondary: var(--fs-text-secondary);
--color-text-muted: var(--fs-text-tertiary);
/* lines */
--color-border: var(--fs-border-color);
--color-input-border: var(--fs-border-color);
--focus-ring: var(--fs-focus-ring);
/* brand */
--color-primary: var(--fs-accent);
--color-primary-solid: var(--fs-accent);
--color-primary-deep: var(--fs-accent-deep);
--color-primary-faint: var(--fs-accent-faint);
--color-primary-tint: var(--fs-accent-soft);
--color-primary-wash: var(--fs-accent-wash);
--color-tag-bg: var(--fs-accent-soft);
--color-tag-text: var(--fs-accent);
--color-wikilink: var(--fs-wikilink);
--color-wikilink-bg: var(--fs-accent-soft);
--gradient-cta: var(--fs-gradient-cta);
--glow-cta: var(--fs-glow-cta);
--glow-cta-hover: var(--fs-glow-cta-hover);
/* actions */
--color-action-primary: var(--fs-action-primary);
--color-action-primary-hover: var(--fs-action-primary-hover);
--color-action-secondary: var(--fs-action-secondary);
--color-action-secondary-hover: var(--fs-action-secondary-hover);
--color-action-destructive: var(--fs-action-destructive);
--color-action-destructive-hover: var(--fs-action-destructive-hover);
/* semantic */
--color-success: var(--fs-success);
--color-warning: var(--fs-warning);
--color-danger: var(--fs-error);
--color-overdue: var(--fs-overdue);
--color-toast-success: var(--fs-success);
--color-toast-error: var(--fs-error);
--color-shadow: rgba(0, 0, 0, 0.4);
/* task status + priority */
--color-status-todo: var(--fs-status-todo);
--color-status-todo-bg: var(--fs-status-todo-bg);
--color-status-in-progress: var(--fs-status-in-progress);
--color-status-in-progress-bg: var(--fs-status-in-progress-bg);
--color-status-done: var(--fs-status-done);
--color-status-done-bg: var(--fs-status-done-bg);
--color-priority-low: var(--fs-priority-low);
--color-priority-low-bg: var(--fs-priority-low-bg);
--color-priority-medium: var(--fs-priority-medium);
--color-priority-medium-bg: var(--fs-priority-medium-bg);
--color-priority-high: var(--fs-priority-high);
--color-priority-high-bg: var(--fs-priority-high-bg);
/* geometry */
--radius-sm: var(--fs-radius-sm);
--radius-md: var(--fs-radius-lg); /* NB: the app's "md" is the system's LARGE */
--radius-lg: var(--fs-radius-xl); /* and the app's "lg" is the system's XL */
--page-max-width: var(--fs-layout-page-max);
--page-padding-x: var(--fs-layout-page-pad);
--sidebar-width: var(--fs-layout-sidebar);
--header-height: var(--fs-layout-header);
/* ------------------------------------------------------------------
Names components reference that were NEVER declared anywhere.
Each of these was reached for with a hardcoded fallback, so the page
rendered — but the fallback was what rendered, always, and several were
off-palette: --color-primary-bg fell back to an indigo, --color-destructive
to a brick that is not the oxblood, --color-status-cancelled to a grey from
no palette in this system.
Wiring them to real tokens is the whole point of the exercise. Expect small
visual shifts exactly where a fallback had drifted; that shift IS the fix.
------------------------------------------------------------------ */
--color-accent: var(--fs-accent);
/* Foreground ON the accent, so it follows the accent's mode-independence,
not the page text's. Pointing this at --fs-text-primary made it invert to
obsidian on light — over a mid-tone accent, well under the AA floor. */
--color-accent-fg: var(--fs-text-on-action);
--color-hover: var(--fs-surface-hover);
--color-bg-hover: var(--fs-surface-hover);
--color-bg-tertiary: var(--fs-surface-hover);
--color-surface-2: var(--fs-surface-hover);
--color-surface-alt: var(--fs-surface-hover);
--color-surface-raised: var(--fs-surface-raised);
--color-input-bg: var(--fs-surface-page);
--color-muted: var(--fs-text-tertiary);
--color-destructive: var(--fs-destructive);
--color-primary-bg: var(--fs-accent-soft);
--color-status-cancelled: var(--fs-status-cancelled);
--font-display: var(--fs-font-display);
--font-mono: var(--fs-font-mono);
}
/* ==========================================================================
@@ -292,10 +377,17 @@ button:not(:disabled):active,
display: none !important;
}
button,
[role="button"] {
[role="button"],
.btn-new-conv,
.btn-send {
min-height: 44px;
}
}
@media (min-width: 769px) {
.hide-desktop {
display: none !important;
}
}
/* Neutral hairline scrollbars — chrome is structural, not branded */
::-webkit-scrollbar {
+12 -12
View File
@@ -15,27 +15,27 @@
white-space: nowrap;
}
.ctx-crumb-parent {
color: var(--fs-text-tertiary);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
color: var(--color-text-muted);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
text-decoration: none;
}
.ctx-crumb-parent:hover {
color: var(--fs-accent);
border-color: var(--fs-accent);
color: var(--color-primary);
border-color: var(--color-primary);
}
.ctx-crumb-project {
color: var(--fs-accent-fg);
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
text-decoration: none;
font-weight: 500;
}
.ctx-crumb-project:hover {
background: color-mix(in srgb, var(--fs-accent) 18%, transparent);
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
}
.ctx-crumb-milestone {
color: var(--fs-text-secondary);
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
}
+88 -83
View File
@@ -6,7 +6,7 @@ import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
import AppLogo from "@/components/AppLogo.vue";
import NotificationBell from "@/components/NotificationBell.vue";
import { Sun, Moon, Settings, Trash2 } from "lucide-vue-next";
import { Sun, Moon, Palette, Settings, Trash2 } from "lucide-vue-next";
const { theme, toggleTheme } = useTheme();
const { toggleShortcuts } = useShortcuts();
@@ -50,13 +50,6 @@ router.afterEach(() => {
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/snippets" class="nav-link">Snippets</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<router-link to="/family" class="nav-link">Family</router-link>
<!-- A design system is a RECORD you author, not a setting. It sat in
the utility cluster with Trash and Settings while /design was a
read-only gallery, and stayed there after it became a record type
with its own table, sharing and MCP tools. Content, by the same
rule that puts Snippets and Rulebooks here. -->
<router-link to="/design-systems" class="nav-link">Design</router-link>
</div>
</div>
@@ -71,6 +64,16 @@ router.afterEach(() => {
<Moon v-else :size="16" />
</button>
<!-- Design. An icon rather than a sixth primary nav link: it's a
meta-surface like Trash and Settings, but hiding it entirely would
defeat the point of having somewhere the design system is visible.
Points at the RECORD, not the live-token view — the record is what
you work with; the live view is the check on it, and it's a tab
away. -->
<router-link to="/design-systems" class="btn-icon" aria-label="Design" title="Design">
<Palette :size="16" />
</router-link>
<!-- Trash link -->
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
<Trash2 :size="16" />
@@ -103,10 +106,9 @@ router.afterEach(() => {
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/snippets" class="nav-link">Snippets</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<router-link to="/family" class="nav-link">Family</router-link>
<router-link to="/design-systems" class="nav-link">Design</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
<router-link to="/design-systems" class="nav-link">Design</router-link>
<router-link to="/trash" class="nav-link">Trash</router-link>
<router-link to="/settings" class="nav-link">Settings</router-link>
<div class="mobile-divider"></div>
@@ -126,35 +128,20 @@ router.afterEach(() => {
<style scoped>
.app-header {
background: linear-gradient(180deg, var(--fs-surface-hover), var(--fs-surface-page));
border-bottom: 1px solid color-mix(in srgb, var(--fs-accent) 18%, transparent);
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
position: relative;
}
/* Three tracks, not a flex row with an absolutely-centred overlay.
*
* The pill bar used to be `position: absolute; left: 50%`, which meant it did
* not participate in layout: when the header ran out of room it OVERLAPPED the
* brand and the utility cluster rather than pushing them, and nothing wrapped
* or scrolled to signal it. A sixth link reached that point at ~1270px, which
* is an ordinary window on any monitor.
*
* `1fr auto 1fr` fixes it structurally. A `1fr` track has an AUTO minimum, so
* neither side can be squeezed below its content, and the two side tracks stay
* equal to each other — which is what keeps the bar centred in the viewport
* rather than merely centred in the leftover space. Overflow becomes the
* header growing, not two things sharing pixels. */
.nav {
padding: 0.6rem 1.5rem;
display: grid;
grid-template-columns: 1fr auto 1fr;
display: flex;
align-items: center;
gap: 0.75rem;
justify-content: space-between;
position: relative;
}
/* Left — brand */
.nav-brand {
justify-self: start;
display: flex;
align-items: center;
gap: 0.45rem;
@@ -172,7 +159,9 @@ router.afterEach(() => {
/* Center — pill bar */
.nav-center {
justify-self: center;
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
}
@@ -180,23 +169,21 @@ router.afterEach(() => {
display: flex;
align-items: center;
gap: 2px;
background: var(--fs-accent-faint);
background: var(--color-primary-faint);
border-radius: 10px;
padding: 3px;
}
/* Right */
.nav-right {
justify-self: end;
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
min-width: 0;
}
.nav-link {
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.82rem;
padding: 0.3rem 0.75rem;
@@ -204,34 +191,72 @@ router.afterEach(() => {
transition: background 0.15s, color 0.15s;
}
.nav-link:hover {
color: var(--fs-text-primary);
background: var(--fs-accent-soft);
color: var(--color-text);
background: var(--color-primary-tint);
}
.nav-link.router-link-active {
color: var(--fs-accent-fg);
color: var(--color-primary-solid);
font-weight: 500;
background: color-mix(in srgb, var(--fs-accent) 25%, transparent);
box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent);
background: rgba(91, 74, 138, 0.25);
box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
}
/* Status indicator */
.status-indicator {
display: flex;
align-items: center;
gap: 0.3rem;
cursor: default;
padding: 0 0.25rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-text {
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-muted);
}
/* Status dots are indicator lights, not semantic-palette buttons —
they want to read as vital (Moss/Warning/Error are too muted for
a "ready" indicator). Hardcoded bright values; the rest of the
system still uses the semantic tokens. */
.status-green .status-dot { background: #4ade80; animation: status-pulse 2.5s ease-in-out infinite; }
.status-yellow .status-dot { background: #facc15; animation: pulse-dot 2s infinite; }
.status-orange .status-dot { background: #f97316; }
.status-red .status-dot { background: #ef4444; }
.status-gray .status-dot { background: var(--color-text-muted); animation: pulse-dot 2s infinite; }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
@keyframes status-pulse {
0%, 100% { box-shadow: 0 0 4px rgba(74, 222, 128, 0.4); }
50% { box-shadow: 0 0 10px rgba(74, 222, 128, 0.6); }
}
/* Icon buttons (?, theme, gear) */
.btn-icon {
background: none;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.25rem 0.45rem;
cursor: pointer;
font-size: 0.95rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover {
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
border-color: var(--fs-accent);
.btn-icon:hover,
.btn-icon.active {
background: var(--color-bg-card);
color: var(--color-text);
border-color: var(--color-primary);
}
/* User info */
@@ -241,42 +266,36 @@ router.afterEach(() => {
gap: 0.4rem;
margin-left: 0.25rem;
padding-left: 0.5rem;
border-left: 1px solid var(--fs-border-color);
border-left: 1px solid var(--color-border);
}
.username {
font-size: 0.85rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
font-weight: 500;
/* The widest thing on the right and the only one that can give: a long
username shouldn't be what decides where the nav bar sits. */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 12ch;
}
.admin-badge {
font-size: 0.65rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-accent-fg);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
padding: 0.1rem 0.35rem;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
}
.btn-logout {
background: none;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.2rem 0.5rem;
cursor: pointer;
font-size: 0.8rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
font-family: inherit;
}
.btn-logout:hover {
color: var(--fs-error);
border-color: var(--fs-error);
color: var(--color-danger);
border-color: var(--color-danger);
}
/* Hamburger — mobile only */
@@ -294,7 +313,7 @@ router.afterEach(() => {
display: block;
width: 20px;
height: 2px;
background: var(--fs-text-primary);
background: var(--color-text);
border-radius: 1px;
}
@@ -303,13 +322,13 @@ router.afterEach(() => {
display: flex;
flex-direction: column;
padding: 0.5rem 1rem 0.75rem;
border-top: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
border-top: 1px solid var(--color-border);
background: var(--color-bg-secondary);
gap: 0.1rem;
}
.mobile-divider {
height: 1px;
background: var(--fs-border-color);
background: var(--color-border);
margin: 0.4rem 0;
}
.mobile-actions {
@@ -323,29 +342,15 @@ router.afterEach(() => {
align-items: center;
gap: 0.5rem;
padding-top: 0.4rem;
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border);
margin-top: 0.25rem;
}
/* The grid above means running out of room can no longer cause a collision —
but it can still make the header wider than the window, and a horizontally
scrolling header is its own defect. So shed width before that happens. The
wordmark goes first: the logo beside it says the same thing and is still the
link home. */
@media (max-width: 1280px) {
.brand-text {
display: none;
}
.nav-link {
padding: 0.3rem 0.5rem;
font-size: 0.78rem;
}
}
@media (max-width: 768px) {
.nav-center {
display: none;
}
.status-indicator,
.btn-icon,
.user-info {
display: none;
@@ -361,7 +366,7 @@ router.afterEach(() => {
border-radius: 8px;
}
.mobile-menu .nav-link.router-link-active {
background: var(--fs-accent-wash);
background: var(--color-primary-wash);
box-shadow: none;
}
.mobile-user .btn-logout {
+5 -5
View File
@@ -13,8 +13,8 @@ defineProps<{ size?: number }>();
>
<defs>
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="var(--fs-accent)" />
<stop offset="100%" stop-color="var(--fs-accent-deep)" />
<stop offset="0%" stop-color="var(--color-primary-solid)" />
<stop offset="100%" stop-color="var(--color-primary-deep)" />
</linearGradient>
</defs>
<!-- Book body -->
@@ -44,12 +44,12 @@ defineProps<{ size?: number }>();
<style scoped>
.logo-book {
fill: url(#logo-gradient);
stroke: color-mix(in srgb, var(--fs-accent) 70%, transparent);
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
}
.logo-spine {
stroke: var(--fs-text-secondary);
stroke: var(--color-text-secondary);
}
.logo-lines {
stroke: var(--fs-text-tertiary);
stroke: var(--color-text-muted);
}
</style>
@@ -1,388 +0,0 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { apiErrorMessage } from "@/api/client";
import {
checkBackupDestination, getBackups, runBackupNow, saveBackups,
type BackupChanges, type BackupConfig, type BackupRun, type BackupsOverview,
} from "@/api/backups";
import { relativeTime } from "@/composables/useRelativeTime";
import { formatBytes } from "@/utils/bytes";
import { fmtStamp } from "@/utils/dateFormat";
import { useToastStore } from "@/stores/toast";
/**
* Scheduled backups (milestone 523): one dated, uncompressed tar per run,
* delivered to a folder or an S3 bucket. The page answers "is my data backed
* up?" first — the verdict, then anything wrong — and the settings follow.
*/
const toastStore = useToastStore();
const data = ref<BackupsOverview | null>(null);
const loading = ref(true);
const loadError = ref("");
const form = ref<BackupConfig | null>(null);
// The secret is never sent back by the server; this holds only what the
// admin types, and is sent only when non-empty.
const secretInput = ref("");
const saving = ref(false);
const checking = ref(false);
const checkResult = ref<{ ok: boolean; message: string } | null>(null);
const starting = ref(false);
const showAllRuns = ref(false);
const RUNS_SHOWN = 8;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let unmounted = false;
async function load() {
loadError.value = "";
try {
const fresh = await getBackups();
data.value = fresh;
// Keep the admin's unsaved edits; take the server's config only on the
// first load or right after a save.
if (!form.value) form.value = { ...fresh.config };
} catch (e: unknown) {
loadError.value = apiErrorMessage(e, "Could not load the backup settings");
} finally {
loading.value = false;
schedulePoll();
}
}
// While a backup runs, follow it. Re-armed from load()'s finally (rule 157),
// so one failed read does not stop the page following the run.
function schedulePoll() {
if (pollTimer) clearTimeout(pollTimer);
pollTimer = null;
if (!unmounted && data.value?.running) pollTimer = setTimeout(load, 3000);
}
onMounted(load);
onBeforeUnmount(() => {
unmounted = true;
if (pollTimer) clearTimeout(pollTimer);
});
const hourLabel = (h: number) => `${String(h).padStart(2, "0")}:00 UTC`;
// ── The verdict, and what is wrong ─────────────────────────────────────────
const verdict = computed(() => {
const d = data.value;
if (!d) return { tone: "", text: "" };
if (d.running) return { tone: "", text: "Backing up now…" };
if (!d.config.enabled) {
return { tone: "muted", text: "Scheduled backups are off. Nothing is backed up unless you run one." };
}
const ok = d.last_success;
if (d.overdue) {
return {
tone: "error",
text: ok
? `Overdue — the last backup that worked was ${relativeTime(ok.finished_at)} (${fmtStamp(ok.finished_at)}).`
: "Overdue — no backup has worked yet.",
};
}
if (!ok) return { tone: "", text: `On — the first backup runs at ${hourLabel(d.config.hour)}.` };
return {
tone: "ok",
text: `Backed up ${relativeTime(ok.finished_at)}: ${ok.file}, ${formatBytes(ok.size_bytes ?? 0)}, to ${ok.where}.`,
};
});
const lastFailure = computed(() => {
const last = data.value?.last_run;
return last && last.status === "failed" ? last : null;
});
const secretUnreadable = computed(() => {
const s = data.value?.secret;
return !!s && s.set && !s.readable;
});
const visibleRuns = computed<BackupRun[]>(() => {
const runs = data.value?.runs ?? [];
return showAllRuns.value ? runs : runs.slice(0, RUNS_SHOWN);
});
const hiddenRuns = computed(() => Math.max(0, (data.value?.runs.length ?? 0) - RUNS_SHOWN));
// ── Actions ────────────────────────────────────────────────────────────────
function changes(): BackupChanges {
const out: BackupChanges = { ...(form.value as BackupConfig) };
if (secretInput.value) out.s3_secret = secretInput.value;
return out;
}
async function save() {
if (!form.value) return;
saving.value = true;
try {
data.value = await saveBackups(changes());
form.value = { ...data.value.config };
secretInput.value = "";
toastStore.show("Backup settings saved");
} catch (e: unknown) {
toastStore.show(apiErrorMessage(e, "Could not save the backup settings"), "error");
} finally {
saving.value = false;
}
}
async function removeSecret() {
try {
data.value = await saveBackups({ clear_s3_secret: true });
toastStore.show("S3 secret removed");
} catch (e: unknown) {
toastStore.show(apiErrorMessage(e, "Could not remove the secret"), "error");
}
}
async function check() {
if (!form.value) return;
checking.value = true;
checkResult.value = null;
try {
checkResult.value = await checkBackupDestination(changes());
} catch (e: unknown) {
checkResult.value = { ok: false, message: apiErrorMessage(e, "The check could not be run") };
} finally {
checking.value = false;
}
}
async function backUpNow() {
starting.value = true;
try {
await runBackupNow();
if (data.value) data.value.running = true;
schedulePoll();
} catch (e: unknown) {
toastStore.show(apiErrorMessage(e, "Could not start a backup"), "error");
} finally {
starting.value = false;
}
}
</script>
<template>
<div class="backups">
<p v-if="loading && !data" class="state">Loading…</p>
<p v-else-if="loadError && !data" class="state state-error" role="alert">
{{ loadError }}
<button type="button" class="btn-text" @click="load">Retry</button>
</p>
<template v-else-if="data && form">
<p :class="['verdict', `verdict-${verdict.tone}`]">{{ verdict.text }}</p>
<p v-if="lastFailure && !data.running" class="problem" role="alert">
The last run failed {{ relativeTime(lastFailure.finished_at) }}: {{ lastFailure.error }}
</p>
<p v-if="secretUnreadable" class="problem" role="alert">
The stored S3 secret can't be opened here — it was saved on an instance with a different
SECRET_KEY. Enter it again below.
</p>
<ul v-if="data.config.enabled && data.problems.length" class="problem-list" role="alert">
<li v-for="p in data.problems" :key="p">{{ p }}</li>
</ul>
<p class="intro">
Each run adds one dated file, <code>scribe-&lt;date&gt;.tar</code>, holding every table
as plain lines. It is not compressed, so a deduplicating tool such as restic stores only what
changed since the day before. Scribe never deletes old backups — the destination keeps and
thins the history. Sharing, API keys and forge connections are not included.
</p>
<div class="row">
<label class="check">
<input v-model="form.enabled" type="checkbox" />
Back up daily
</label>
<label class="inline-field">
at
<select v-model.number="form.hour" class="fs-input" aria-label="Backup hour (UTC)">
<option v-for="h in 24" :key="h - 1" :value="h - 1">{{ hourLabel(h - 1) }}</option>
</select>
</label>
</div>
<fieldset class="destination">
<legend>Send each backup to</legend>
<label class="check">
<input v-model="form.destination" type="radio" value="folder" />
A folder
</label>
<label class="check">
<input v-model="form.destination" type="radio" value="s3" />
An S3 bucket
</label>
</fieldset>
<div v-if="form.destination === 'folder'" class="fields">
<label class="field">
<span>Folder</span>
<input v-model.trim="form.folder_path" class="fs-input" placeholder="/backups" />
<span class="field-hint">
A path inside the Scribe container — usually a share mounted there that a backup
service watches.
</span>
</label>
</div>
<div v-else class="fields">
<label class="field">
<span>Address</span>
<input v-model.trim="form.s3_endpoint" class="fs-input" placeholder="https://backup.example.com" />
<span class="field-hint">The S3 service itself, without the bucket. Buckets are addressed by path.</span>
</label>
<label class="field">
<span>Bucket</span>
<input v-model.trim="form.s3_bucket" class="fs-input" placeholder="scribe" />
</label>
<label class="field">
<span>Access key</span>
<input v-model.trim="form.s3_access_key" class="fs-input" autocomplete="off" />
</label>
<label class="field">
<span>Secret key</span>
<input
v-model="secretInput"
type="password"
class="fs-input"
autocomplete="new-password"
:placeholder="data.secret.set && data.secret.readable ? 'Saved — type a new one to replace it' : ''"
/>
<span class="field-hint">
Stored encrypted, and never included in a backup. A key that may only add files is enough.
<button
v-if="data.secret.set"
type="button"
class="btn-text"
@click="removeSecret"
>Remove the saved secret</button>
</span>
</label>
<label class="field">
<span>Region</span>
<input v-model.trim="form.s3_region" class="fs-input" placeholder="us-east-1" />
</label>
<label class="check">
<input v-model="form.s3_verify_tls" type="checkbox" />
Check the address's TLS certificate
</label>
</div>
<div class="actions">
<button type="button" class="btn-primary" :disabled="saving" @click="save">
{{ saving ? "Saving…" : "Save" }}
</button>
<button type="button" class="btn-secondary" :disabled="checking" @click="check">
{{ checking ? "Checking…" : "Test destination" }}
</button>
<button
type="button"
class="btn-secondary"
:disabled="starting || data.running"
@click="backUpNow"
>
{{ data.running ? "Backing up…" : "Back up now" }}
</button>
</div>
<p v-if="checkResult" :class="['check-result', checkResult.ok ? 'check-ok' : 'check-bad']" role="status">
{{ checkResult.message }}
</p>
<h3 class="runs-label">Recent runs</h3>
<p v-if="!data.runs.length" class="state">None yet.</p>
<div v-else class="runs-scroll">
<table class="runs">
<thead>
<tr><th>When</th><th>Result</th><th>File</th><th class="num">Size</th><th class="num">Took</th></tr>
</thead>
<tbody>
<tr v-for="r in visibleRuns" :key="r.started_at" :class="{ failed: r.status === 'failed' }">
<td :title="fmtStamp(r.started_at)">
{{ relativeTime(r.started_at) }}
<span v-if="r.trigger === 'manual'" class="by-hand">by hand</span>
</td>
<td>{{ r.status === "ok" ? "Backed up" : r.error }}</td>
<td><code v-if="r.file">{{ r.file }}</code><span v-else>—</span></td>
<td class="num">{{ r.size_bytes != null ? formatBytes(r.size_bytes) : "—" }}</td>
<td class="num">{{ r.duration_s }} s</td>
</tr>
</tbody>
</table>
<button v-if="hiddenRuns && !showAllRuns" type="button" class="btn-text" @click="showAllRuns = true">
+{{ hiddenRuns }} earlier
</button>
</div>
</template>
</div>
</template>
<style scoped>
.backups { display: flex; flex-direction: column; gap: var(--fs-space-3); }
.state { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
.state-error { color: var(--fs-error); }
.verdict { margin: 0; font-size: var(--fs-size-body); color: var(--fs-text-primary); }
.verdict-ok { color: var(--fs-success); }
.verdict-error { color: var(--fs-error); }
.verdict-muted { color: var(--fs-text-secondary); }
.problem, .problem-list {
margin: 0;
font-size: var(--fs-size-body-sm);
color: var(--fs-error);
}
.problem-list { padding-left: var(--fs-space-4); }
.intro { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); line-height: var(--fs-leading-body); }
.intro code, .runs code { font-family: var(--fs-font-mono); font-size: var(--fs-size-tiny); }
.row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-3); }
.check { display: inline-flex; align-items: center; gap: var(--fs-space-2); font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); }
.inline-field { display: inline-flex; align-items: center; gap: var(--fs-space-2); font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
.inline-field select { width: auto; }
.destination {
margin: 0; padding: 0; border: 0;
display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-3);
}
.destination legend {
float: left; margin-right: var(--fs-space-2);
font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary);
}
.fields { display: flex; flex-direction: column; gap: var(--fs-space-3); max-width: 32rem; }
.field { display: flex; flex-direction: column; gap: var(--fs-space-1); }
.field > span:first-child { font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
.actions { display: flex; flex-wrap: wrap; gap: var(--fs-space-2); }
.check-result { margin: 0; font-size: var(--fs-size-body-sm); }
.check-ok { color: var(--fs-success); }
.check-bad { color: var(--fs-error); }
.runs-label {
margin: var(--fs-space-2) 0 0;
font-size: var(--fs-size-tiny); font-weight: normal;
text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny);
color: var(--fs-text-tertiary);
}
.runs-scroll { overflow-x: auto; }
.runs { width: 100%; border-collapse: collapse; font-size: var(--fs-size-body-sm); }
.runs th {
text-align: left; font-weight: normal;
font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary);
padding: var(--fs-space-1) var(--fs-space-2);
border-bottom: 1px solid var(--fs-border-color);
}
.runs td {
padding: var(--fs-space-1) var(--fs-space-2);
color: var(--fs-text-primary);
border-bottom: 1px solid var(--fs-border-color);
vertical-align: baseline;
}
.runs .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.runs tr.failed td { color: var(--fs-error); }
.by-hand { margin-left: var(--fs-space-1); font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); }
</style>
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
/**
* Sub-navigation for the Design surface.
*
* There are two pages here and they are halves of ONE thing: the record that
* decides the styling, and what the browser is actually rendering from it. They
* were briefly two top-level nav entries, which put the read-only diagnostic
* first and buried the editable record under it — backwards, since the record
* is the thing you work with and the live view is the check on it.
*
* A component rather than the same markup pasted into both views: two copies of
* a tab bar diverge the moment a third tab appears, and that is the exact shape
* of duplication this whole surface exists to make visible.
*/
</script>
<template>
<nav class="design-tabs" aria-label="Design views">
<router-link to="/design-systems" class="design-tab">Design system</router-link>
<router-link to="/design" class="design-tab">Live tokens</router-link>
</nav>
</template>
<style scoped>
.design-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--color-border);
}
.design-tab {
padding: 0.5rem 0.9rem;
font-size: 0.9rem;
color: var(--color-text-secondary);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.design-tab:hover {
color: var(--color-text);
}
/* `router-link-active` rather than `-exact-active`: both routes are leaves, and
exact matching would drop the highlight on any future child route. */
.design-tab.router-link-active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
</style>
+16 -16
View File
@@ -90,9 +90,9 @@ function markerFor(type: DiffLine['type']): string {
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
overflow: hidden;
}
@@ -103,15 +103,15 @@ function markerFor(type: DiffLine['type']): string {
display: flex;
gap: 1rem;
padding: 0.4rem 0.75rem;
background: var(--fs-surface-raised);
border-bottom: 1px solid var(--fs-border-color);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
font-size: 0.78rem;
font-family: monospace;
font-weight: 600;
}
.diff-summary-ins { color: var(--fs-success); }
.diff-summary-del { color: var(--fs-error); }
.diff-summary-ins { color: var(--color-success, #2ecc71); }
.diff-summary-del { color: var(--color-danger, #e74c3c); }
.diff-scroll {
flex: 1;
@@ -123,7 +123,7 @@ function markerFor(type: DiffLine['type']): string {
.diff-empty {
padding: 0.75rem;
font-size: 0.85rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.diff-line {
@@ -136,24 +136,24 @@ function markerFor(type: DiffLine['type']): string {
}
.diff-delete {
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
color: var(--fs-error-fg);
background: color-mix(in srgb, var(--color-danger, #e74c3c) 12%, transparent);
color: var(--color-danger, #e74c3c);
}
.diff-insert {
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
color: var(--fs-success-fg);
background: color-mix(in srgb, var(--color-success, #2ecc71) 12%, transparent);
color: var(--color-success, #2ecc71);
}
.diff-equal {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.diff-collapse {
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
opacity: 0.6;
border-top: 1px dashed var(--fs-border-color);
border-bottom: 1px dashed var(--fs-border-color);
border-top: 1px dashed var(--color-border);
border-bottom: 1px dashed var(--color-border);
padding-top: 0.2rem;
padding-bottom: 0.2rem;
}
@@ -1,340 +0,0 @@
<script setup lang="ts">
/**
* The adoption matrix (milestone 463 step 4): every project × every canon
* family idea that reaches it, and the project's answer.
*
* Nobody approves an answer — the agent assesses each project against the
* four outcomes and records why. This is where a person sees that happening:
* which projects adopted, which depart and for what fact, which owe the work
* (with the task filed in that project), and which answers were given
* against an older version of the idea and need a recheck. Disagreeing is an
* undo in the decision log below.
*
* `projectId` narrows it to one project's answers — the same component, so a
* project's Family tab and this page cannot describe a cell differently.
*/
import { computed, onMounted, ref, watch } from "vue";
import {
fetchAdoptionMatrix,
type AdoptionCell, type AdoptionMatrix, type AdoptionStatus,
} from "@/api/family";
import { usePlatformsStore } from "@/stores/platforms";
import { fmtStamp } from "@/utils/dateFormat";
import { recordHref } from "@/utils/recordHref";
const props = defineProps<{ projectId?: number }>();
const platformsStore = usePlatformsStore();
const matrix = ref<AdoptionMatrix | null>(null);
const platform = ref("");
const loading = ref(true);
const failed = ref(false);
const selectedKey = ref<string | null>(null);
const LABELS: Record<AdoptionStatus, string> = {
unassessed: "Not assessed",
adopted: "Adopted",
variant: "Variant",
exempt: "Doesn't apply",
owed: "Owed",
};
async function load() {
loading.value = true;
failed.value = false;
try {
const [, data] = await Promise.all([
platformsStore.fetchCatalog(),
fetchAdoptionMatrix({ platform: platform.value, projectId: props.projectId }),
]);
matrix.value = data;
if (selectedKey.value && !data.cells.some((c) => key(c) === selectedKey.value)) {
selectedKey.value = null;
}
} catch {
// A matrix that failed to load must not read as "nobody owes anything".
failed.value = true;
} finally {
loading.value = false;
}
}
function key(c: { project_id: number; idea_id: number }): string {
return `${c.project_id}:${c.idea_id}`;
}
const cellByKey = computed(() => {
const out: Record<string, AdoptionCell> = {};
for (const c of matrix.value?.cells ?? []) out[key(c)] = c;
return out;
});
const selected = computed(() =>
selectedKey.value ? cellByKey.value[selectedKey.value] ?? null : null,
);
const outcomeTest = computed(() => {
const out: Partial<Record<AdoptionStatus, string>> = {};
for (const o of matrix.value?.outcomes ?? []) out[o.key] = o.test;
return out;
});
/** The answers that want attention: owed, or given against an older canon. */
const summary = computed(() => {
const cells = matrix.value?.cells ?? [];
return {
owed: cells.filter((c) => c.status === "owed").length,
recheck: cells.filter((c) => c.needs_recheck).length,
unassessed: cells.filter((c) => c.status === "unassessed").length,
};
});
function toggle(c: AdoptionCell) {
selectedKey.value = selectedKey.value === key(c) ? null : key(c);
}
function cellTitle(c: AdoptionCell): string {
const parts = [LABELS[c.status]];
if (c.needs_recheck) parts.push(`answered against v${c.canon_version}, now v${c.idea_version}`);
if (c.reason) parts.push(c.reason);
return parts.join(" — ");
}
onMounted(load);
watch(platform, load);
defineExpose({ load });
</script>
<template>
<div class="fam-matrix-block">
<div class="fam-matrix-head">
<p class="fam-muted">
<template v-if="projectId">This project's answer to each family idea that reaches it.</template>
<template v-else>Each project's answer to each canon idea that reaches it.</template>
The agent answers by the four outcomes below and records why; “recheck” means
the idea changed after it was answered.
</p>
<label v-if="platformsStore.catalog.length" class="fam-select-label">
Platform
<select v-model="platform" class="fam-select">
<option value="">All</option>
<option v-for="p in platformsStore.catalog" :key="p.slug" :value="p.slug">{{ p.name }}</option>
</select>
</label>
</div>
<p v-if="loading && !matrix" class="fam-muted">Loading…</p>
<div v-else-if="failed" class="fam-note">
<strong>The adoption matrix couldn't load.</strong>
<p>Nothing is known either way — this is a failure, not an empty ledger.</p>
<button class="btn-secondary btn-sm" @click="load">Try again</button>
</div>
<template v-else-if="matrix">
<p v-if="!matrix.ideas.length" class="fam-muted">
<template v-if="platform">No canon idea is for this platform yet.</template>
<template v-else>No idea is canon yet, so nothing has reached a project.</template>
</p>
<p v-else-if="!matrix.projects.length" class="fam-muted">
<template v-if="projectId">
No canon idea reaches this project — it is on none of their platforms.
</template>
<template v-else>
No project is on a platform any canon idea is for. A project joins a platform
on its Family tab, or by detection from a bound repo.
</template>
</p>
<template v-else>
<p class="fam-summary">
{{ summary.owed }} owed · {{ summary.recheck }} to recheck ·
{{ summary.unassessed }} not assessed
</p>
<div class="fam-matrix-wrap" role="region" aria-label="Adoption matrix" tabindex="0">
<table class="fam-matrix">
<thead>
<tr>
<th scope="col" class="fam-corner">Project</th>
<th v-for="idea in matrix.ideas" :key="idea.note_id" scope="col">
<router-link
:to="recordHref({ id: idea.note_id, is_task: idea.is_task, note_type: idea.note_type })"
:title="idea.applies_when ? `Applies when: ${idea.applies_when}` : undefined"
>{{ idea.title }}</router-link>
<span class="fam-version">v{{ idea.canon_version }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="p in matrix.projects" :key="p.id">
<th scope="row">
<router-link :to="`/projects/${p.id}`">{{ p.title }}</router-link>
</th>
<td v-for="idea in matrix.ideas" :key="idea.note_id">
<button
v-if="cellByKey[`${p.id}:${idea.note_id}`]"
type="button"
:class="['fam-cell', `fam-cell-${cellByKey[`${p.id}:${idea.note_id}`].status}`,
{ 'fam-cell-on': selectedKey === `${p.id}:${idea.note_id}` }]"
:aria-pressed="selectedKey === `${p.id}:${idea.note_id}`"
:title="cellTitle(cellByKey[`${p.id}:${idea.note_id}`])"
@click="toggle(cellByKey[`${p.id}:${idea.note_id}`])"
>
{{ LABELS[cellByKey[`${p.id}:${idea.note_id}`].status] }}
<span v-if="cellByKey[`${p.id}:${idea.note_id}`].needs_recheck" class="fam-recheck">recheck</span>
</button>
<span v-else class="fam-cell-none" title="Not on this idea's platforms">—</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="selected" class="fam-detail" aria-live="polite">
<div class="fam-detail-head">
<strong>{{ selected.project_title }} · {{ selected.idea_title }}</strong>
<span :class="['fam-cell', `fam-cell-${selected.status}`]">{{ LABELS[selected.status] }}</span>
<button type="button" class="btn-ghost btn-sm" @click="selectedKey = null">Close</button>
</div>
<p v-if="selected.reason" class="fam-detail-reason">{{ selected.reason }}</p>
<p v-else-if="!selected.reached" class="fam-muted">
The idea reaches this project, but nobody has been asked yet — whoever promoted
it could not write here. The project's own agent answers it on its next pass.
</p>
<p v-else-if="selected.status === 'unassessed'" class="fam-muted">
Waiting for the agent to assess it against the four outcomes.
</p>
<p v-if="selected.needs_recheck" class="fam-detail-recheck">
Answered against v{{ selected.canon_version }}; the idea is now
v{{ selected.idea_version }}, so this answer needs a recheck.
</p>
<p v-if="!selected.in_scope" class="fam-muted">
This project is no longer on the idea's platforms; the answer is kept as history.
</p>
<p v-if="selected.owed_task" class="fam-detail-task">
Owed task:
<router-link :to="recordHref({ id: selected.owed_task.id, is_task: true })">
{{ selected.owed_task.title }}
</router-link>
<span class="fam-muted">({{ selected.owed_task.status.replace("_", " ") }})</span>
</p>
<div v-if="selected.shapes.length" class="fam-detail-shapes">
<p>In this project's code — the shape ledger classifies:</p>
<ul>
<li v-for="s in selected.shapes" :key="`${s.path}::${s.symbol}`">
<code>{{ s.path }} · {{ s.symbol }}</code>
<span class="fam-muted">{{ s.status === "variant" ? "variant" : "instance" }}</span>
</li>
</ul>
</div>
<p v-if="selected.assessed_at" class="fam-muted">
Answered {{ fmtStamp(selected.assessed_at) }}
<template v-if="selected.decided_via">by the {{ selected.decided_via }}</template>
<template v-if="selected.canon_version"> against v{{ selected.canon_version }}</template>.
</p>
</div>
</template>
<details class="fam-outcomes">
<summary>The four outcomes</summary>
<dl>
<template v-for="o in matrix.outcomes" :key="o.key">
<dt>{{ LABELS[o.key] }}</dt>
<dd>{{ outcomeTest[o.key] }}</dd>
</template>
</dl>
</details>
</template>
</div>
</template>
<style scoped>
.fam-matrix-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 0.5rem 1rem; margin-bottom: 0.75rem; }
.fam-matrix-head .fam-muted { flex: 1; min-width: 16rem; max-width: 52rem; }
.fam-muted { color: var(--fs-text-tertiary); font-size: 0.85rem; margin: 0; }
.fam-note {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
padding: 0.85rem 1rem;
}
.fam-note p { margin: 0.35rem 0 0.6rem; color: var(--fs-text-secondary); font-size: 0.875rem; }
.fam-select-label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
.fam-select {
padding: 0.3rem 0.5rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.85rem;
}
.fam-summary { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
.fam-matrix-wrap {
overflow-x: auto;
max-width: 100%;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
.fam-matrix-wrap:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
button.fam-cell:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
.fam-matrix { border-collapse: collapse; font-size: 0.85rem; min-width: 100%; }
.fam-matrix th, .fam-matrix td {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--fs-border-color);
text-align: left;
vertical-align: middle;
}
.fam-matrix thead th { font-weight: 500; min-width: 9rem; max-width: 14rem; }
.fam-matrix tbody th { font-weight: 500; white-space: nowrap; }
.fam-matrix tbody tr:last-child th, .fam-matrix tbody tr:last-child td { border-bottom: none; }
.fam-corner { color: var(--fs-text-tertiary); }
.fam-version { display: block; color: var(--fs-text-tertiary); font-size: 0.75rem; font-weight: 400; }
.fam-cell {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font: inherit;
font-size: 0.78rem;
padding: 0.1rem 0.5rem;
border-radius: var(--fs-radius-pill);
border: 1px solid var(--fs-border-color);
background: transparent;
color: var(--fs-text-secondary);
white-space: nowrap;
}
button.fam-cell { cursor: pointer; }
button.fam-cell:hover { background: var(--fs-surface-hover); }
.fam-cell-on { outline: 2px solid var(--fs-text-secondary); outline-offset: 1px; }
.fam-cell-adopted { background: var(--fs-status-done-bg); color: var(--fs-status-done-fg); }
.fam-cell-owed { background: var(--fs-status-todo-bg); color: var(--fs-status-todo-fg); }
.fam-cell-variant { background: var(--fs-status-in-progress-bg); color: var(--fs-status-in-progress-fg); }
.fam-cell-exempt { color: var(--fs-status-cancelled-fg); }
.fam-cell-unassessed { color: var(--fs-text-tertiary); border-style: dashed; }
.fam-recheck {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--fs-text-primary);
}
.fam-cell-none { color: var(--fs-text-tertiary); }
.fam-detail {
margin-top: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
background: var(--fs-surface-raised);
}
.fam-detail-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; }
.fam-detail-head .btn-ghost { margin-left: auto; }
.fam-detail p { margin: 0.4rem 0 0; font-size: 0.875rem; }
.fam-detail-reason { color: var(--fs-text-primary); }
.fam-detail-recheck { color: var(--fs-text-secondary); }
.fam-detail-shapes ul { margin: 0.25rem 0 0; padding-left: 1.1rem; font-size: 0.85rem; }
.fam-detail-shapes li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 0.5rem; overflow-wrap: anywhere; }
.fam-detail-shapes code { font-family: var(--fs-font-mono); }
.fam-outcomes { margin-top: 0.75rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
.fam-outcomes summary { cursor: pointer; color: var(--fs-text-secondary); }
.fam-outcomes dl { margin: 0.5rem 0 0; }
.fam-outcomes dt { font-weight: 500; color: var(--fs-text-primary); }
.fam-outcomes dd { margin: 0 0 0.4rem 1rem; }
</style>
+57 -30
View File
@@ -2,8 +2,7 @@
import { ref, computed, onMounted } from "vue";
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
import DiffView from "@/components/DiffView.vue";
import { computeDiff, type DiffLine } from "@/utils/diff";
import { fmtStamp } from "@/utils/dateFormat";
import type { DiffLine } from "@/composables/useAssist";
interface NoteVersion {
id: number;
@@ -33,10 +32,38 @@ const loadingDetail = ref(false);
const diff = computed<DiffLine[]>(() => {
if (!selectedVersion.value?.body) return [];
const a = props.currentBody;
const b = selectedVersion.value.body;
return computeDiff(props.currentBody, selectedVersion.value.body);
const aLines = a.split('\n');
const bLines = b.split('\n');
const m = aLines.length, n = bLines.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i--)
for (let j = n - 1; j >= 0; j--)
dp[i][j] = aLines[i] === bLines[j]
? dp[i+1][j+1] + 1
: Math.max(dp[i+1][j], dp[i][j+1]);
const result: DiffLine[] = [];
let i = 0, j = 0;
while (i < m && j < n) {
if (aLines[i] === bLines[j]) { result.push({ type: 'equal', text: aLines[i++] }); j++; }
else if (dp[i+1][j] >= dp[i][j+1]) result.push({ type: 'delete', text: aLines[i++] });
else result.push({ type: 'insert', text: bLines[j++] });
}
while (i < m) result.push({ type: 'delete', text: aLines[i++] });
while (j < n) result.push({ type: 'insert', text: bLines[j++] });
return result;
});
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
async function loadVersions() {
loading.value = true;
try {
@@ -185,7 +212,7 @@ onMounted(loadVersions);
v-if="v.pin_kind === 'manual' && v.pin_label"
class="history-item-label"
>{{ v.pin_label }}</div>
<div class="history-item-date">{{ fmtStamp(v.created_at) }}</div>
<div class="history-item-date">{{ formatDate(v.created_at) }}</div>
</div>
</div>
@@ -282,7 +309,7 @@ onMounted(loadVersions);
align-items: center;
justify-content: space-between;
padding: 0.9rem 1.25rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.history-title {
@@ -295,11 +322,11 @@ onMounted(loadVersions);
border: none;
font-size: 1.25rem;
cursor: pointer;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
line-height: 1;
padding: 0.1rem 0.3rem;
}
.history-close:hover { color: var(--fs-text-primary); }
.history-close:hover { color: var(--color-text); }
.history-body {
flex: 1;
@@ -311,7 +338,7 @@ onMounted(loadVersions);
.history-list {
width: 220px;
flex-shrink: 0;
border-right: 1px solid var(--fs-border-color);
border-right: 1px solid var(--color-border);
overflow-y: auto;
}
@@ -319,18 +346,18 @@ onMounted(loadVersions);
padding: 0.6rem 0.9rem;
cursor: pointer;
border-left: 3px solid transparent;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
}
.history-item:hover { background: var(--fs-surface-raised); }
.history-item:hover { background: var(--color-bg-secondary); }
.history-item.selected {
border-left-color: var(--fs-accent);
background: var(--fs-surface-raised);
border-left-color: var(--color-primary);
background: var(--color-bg-secondary);
}
.history-item-title {
font-size: 0.85rem;
font-weight: 500;
color: var(--fs-text-primary);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -338,7 +365,7 @@ onMounted(loadVersions);
.history-item-date {
font-size: 0.75rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
margin-top: 0.15rem;
}
@@ -354,7 +381,7 @@ onMounted(loadVersions);
.history-empty {
padding: 1rem;
font-size: 0.85rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
.history-footer {
@@ -363,7 +390,7 @@ onMounted(loadVersions);
gap: 0.5rem;
justify-content: flex-end;
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border);
}
@@ -376,12 +403,12 @@ onMounted(loadVersions);
font-size: 0.85em;
line-height: 1;
}
.pin-badge-manual { color: var(--fs-accent); }
.pin-badge-auto { color: var(--fs-text-tertiary); }
.pin-badge-manual { color: var(--color-primary, #6366f1); }
.pin-badge-auto { color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); }
.history-item-label {
font-size: 0.72rem;
color: var(--fs-accent);
color: var(--color-primary, #6366f1);
font-style: italic;
margin-top: 0.15rem;
overflow: hidden;
@@ -392,7 +419,7 @@ onMounted(loadVersions);
/* ── Pin controls above the diff ────────────────────────────────────────── */
.version-pin-controls {
padding: 0.4rem 0.5rem 0.5rem;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
font-size: 0.82rem;
}
.pin-actions {
@@ -403,7 +430,7 @@ onMounted(loadVersions);
}
.pin-state {
font-style: italic;
color: var(--fs-text-tertiary);
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
flex: 1;
min-width: 0;
overflow: hidden;
@@ -415,13 +442,13 @@ onMounted(loadVersions);
font-size: 0.78rem;
background: transparent;
color: inherit;
border: 1px solid var(--fs-border-color);
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
border-radius: 999px;
cursor: pointer;
}
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.12);
border-color: var(--fs-accent);
border-color: var(--color-primary, #6366f1);
}
.btn-unpin:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.10);
@@ -436,27 +463,27 @@ onMounted(loadVersions);
flex: 1;
padding: 0.3rem 0.5rem;
font-size: 0.85rem;
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
border-radius: var(--radius-sm, 4px);
color: inherit;
}
.pin-label-input:focus {
outline: none;
border-color: var(--fs-accent);
border-color: var(--color-primary, #6366f1);
}
.btn-pin-save, .btn-pin-cancel {
padding: 0.3rem 0.7rem;
font-size: 0.78rem;
background: transparent;
color: inherit;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
border-radius: var(--radius-sm, 4px);
cursor: pointer;
}
.btn-pin-save:hover:not(:disabled) {
background: rgba(99, 102, 241, 0.12);
border-color: var(--fs-accent);
border-color: var(--color-primary, #6366f1);
}
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
-213
View File
@@ -1,213 +0,0 @@
<script setup lang="ts">
/**
* The inception form (milestone 297): "what does this project inherit?"
*
* Two homes, one component. mode="create" rides the New-project modal's
* second step and only emits the choices (the project does not exist yet);
* mode="decide" sits on ProjectView for an undecided project, loads that
* project's current defaults, and records the decision itself.
*
* Platforms (milestone 463) start UNANSWERED (null). Ticking any box makes
* the list the whole answer; in decide mode the boxes start from what
* detection already found, so recording confirms it.
*/
import { computed, onMounted, ref, watch } from "vue";
import { apiErrorMessage } from "@/api/client";
import { fetchDesignSystems } from "@/api/designSystems";
import { MEMBER_STATES } from "@/api/platforms";
import { usePlatformsStore } from "@/stores/platforms";
import {
decideInception, emptyChoices, fetchInceptionDefaults,
type InceptionChoices, type InceptionDecision, type InceptionDefaults,
} from "@/api/inception";
const props = withDefaults(defineProps<{
mode: "create" | "decide";
projectId?: number;
choices?: InceptionChoices;
}>(), { projectId: 0, choices: undefined });
const emit = defineEmits<{
"update:choices": [value: InceptionChoices];
decided: [decision: InceptionDecision];
}>();
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
const designSystems = ref<{ id: number; title: string }[]>([]);
const systemsCount = ref(0);
const platforms = ref<{ slug: string; name: string }[]>([]);
const detected = ref<Set<string>>(new Set());
const platformsStore = usePlatformsStore();
const loading = ref(true);
const saving = ref(false);
const error = ref("");
function emitChoices() {
emit("update:choices", { ...local.value });
}
watch(local, emitChoices, { deep: true });
async function load() {
loading.value = true;
error.value = "";
try {
if (props.mode === "decide" && props.projectId) {
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
designSystems.value = d.design_systems;
systemsCount.value = d.systems;
platforms.value = d.platforms;
const members = d.project_platforms
.filter((p) => MEMBER_STATES.includes(p.state))
.map((p) => p.slug);
detected.value = new Set(
d.project_platforms.filter((p) => p.state === "detected").map((p) => p.slug),
);
// Start from what stands today, so "record" without changes keeps it.
local.value = {
design_system_id: d.design_system_id,
seed_systems: false,
platforms: members.length ? members : null,
};
} else {
const [ds, catalog] = await Promise.all([
fetchDesignSystems(), platformsStore.fetchCatalog(),
]);
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
platforms.value = catalog.map((p) => ({ slug: p.slug, name: p.name }));
}
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not load what this project could inherit");
} finally {
loading.value = false;
}
}
const nothingToDecide = computed(() => !designSystems.value.length);
function isChecked(slug: string): boolean {
return (local.value.platforms ?? []).includes(slug);
}
function togglePlatform(slug: string, on: boolean) {
const current = new Set(local.value.platforms ?? []);
if (on) current.add(slug);
else current.delete(slug);
local.value.platforms = [...current].sort();
}
/** Back to "not answered" — distinct from an empty list, which says "none". */
function clearPlatforms() {
local.value.platforms = null;
}
async function record() {
if (!props.projectId) return;
saving.value = true;
error.value = "";
try {
const decision = await decideInception(props.projectId, local.value);
emit("decided", decision);
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not record the decision");
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="inception" aria-labelledby="inception-title">
<h3 id="inception-title" class="inception-title">What does this project inherit?</h3>
<p class="inception-lede">
A project's inheritance is a decision, not a default. Until it is recorded,
there is no design system and no Systems. Rules aren't part of this: global
rules apply to every project, and a project's own rules are added on it.
</p>
<p v-if="loading" class="inception-muted">Loading…</p>
<p v-else-if="error" class="error-msg">{{ error }}</p>
<template v-else>
<div class="inception-group">
<h4>Design system</h4>
<select v-model="local.design_system_id" class="inception-select" aria-label="Design system">
<option :value="null">None</option>
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
</select>
</div>
<div class="inception-group">
<label class="inception-choice">
<input type="checkbox" v-model="local.seed_systems" :disabled="systemsCount > 0" />
<span>
Seed the standard starter Systems (CI &amp; Release, Auth &amp; Access, Data Model &amp; Storage, …)
<em v-if="systemsCount > 0" class="inception-muted"> — this project already has {{ systemsCount }}</em>
</span>
</label>
</div>
<div v-if="platforms.length" class="inception-group">
<h4>Platforms</h4>
<p class="inception-muted">
What it is built on or ships as. Ideas the family has proven for a
platform reach every project that is one.
<template v-if="local.platforms === null"> Not answered yet.</template>
<template v-else>
Anything left unticked is recorded as "not this project".
<button type="button" class="btn-ghost btn-sm" @click="clearPlatforms">Leave unanswered</button>
</template>
</p>
<div class="inception-platforms">
<label v-for="p in platforms" :key="p.slug" class="inception-choice">
<input
type="checkbox"
:checked="isChecked(p.slug)"
@change="togglePlatform(p.slug, ($event.target as HTMLInputElement).checked)"
/>
<span>
{{ p.name }}
<em v-if="detected.has(p.slug)" class="inception-muted"> — detected</em>
</span>
</label>
</div>
</div>
<p v-if="nothingToDecide" class="inception-muted">
No design systems on this install yet — recording still settles the question.
</p>
<div v-if="mode === 'decide'" class="inception-actions">
<button class="btn-primary" :disabled="saving" @click="record">
{{ saving ? "Recording…" : "Record decision" }}
</button>
</div>
</template>
</section>
</template>
<style scoped>
.inception {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
padding: 1.25rem 1.5rem;
margin-bottom: 1.5rem;
}
.inception-title { margin: 0 0 0.35rem; font-size: 1.05rem; }
.inception-lede { margin: 0 0 1rem; color: var(--fs-text-secondary); font-size: 0.9rem; }
.inception-muted { color: var(--fs-text-tertiary); font-size: 0.85rem; margin: 0 0 0.35rem; }
.inception-group { margin-bottom: 1rem; }
.inception-group h4 { margin: 0 0 0.35rem; font-size: 0.9rem; font-weight: 500; }
.inception-choice { display: flex; align-items: flex-start; gap: 0.5rem; font-size: 0.9rem; margin: 0.25rem 0; }
.inception-choice input { margin-top: 0.2rem; accent-color: var(--fs-accent); }
.inception-platforms {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
gap: 0 1rem;
}
.inception-select {
padding: 0.45rem 0.7rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.9rem;
}
.inception-actions { display: flex; justify-content: flex-end; margin-top: 0.5rem; }
</style>
+34 -34
View File
@@ -74,11 +74,11 @@ const markers: Record<DiffLine["type"], string> = {
<style scoped>
.iap {
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
margin-bottom: 0.75rem;
overflow: hidden;
border: 1px solid var(--fs-border-color);
background: var(--fs-surface-page);
border: 1px solid var(--color-border);
background: var(--color-bg);
}
/* ── Header ── */
@@ -88,16 +88,16 @@ const markers: Record<DiffLine["type"], string> = {
gap: 0.5rem;
padding: 0.45rem 0.75rem;
font-size: 0.85rem;
border-bottom: 1px solid var(--fs-border-color);
background: var(--fs-surface-raised);
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
}
/* ── Streaming ── */
.iap-streaming {
border-color: var(--fs-accent);
border-color: var(--color-primary);
}
.iap-streaming .iap-header {
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
}
.iap-pulse {
@@ -105,7 +105,7 @@ const markers: Record<DiffLine["type"], string> = {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--fs-accent);
background: var(--color-primary);
flex-shrink: 0;
animation: iap-pulse 1.2s ease-in-out infinite;
}
@@ -117,7 +117,7 @@ const markers: Record<DiffLine["type"], string> = {
.iap-label {
flex: 1;
font-weight: 500;
color: var(--fs-text-primary);
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -125,9 +125,9 @@ const markers: Record<DiffLine["type"], string> = {
.iap-btn-cancel {
background: none;
border: 1px solid var(--fs-border-color);
color: var(--fs-text-secondary);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
padding: 0.15rem 0.5rem;
cursor: pointer;
font-size: 0.8rem;
@@ -135,8 +135,8 @@ const markers: Record<DiffLine["type"], string> = {
flex-shrink: 0;
}
.iap-btn-cancel:hover {
border-color: var(--fs-error);
color: var(--fs-error);
border-color: var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
}
.iap-stream-preview {
@@ -150,29 +150,29 @@ const markers: Record<DiffLine["type"], string> = {
.iap-waiting {
padding: 0.75rem;
font-size: 0.85rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
/* ── Review ── */
.iap-review-title {
flex: 1;
font-weight: 600;
color: var(--fs-text-primary);
color: var(--color-text);
}
.iap-btn-toggle {
background: none;
border: 1px solid var(--fs-border-color);
color: var(--fs-text-secondary);
border-radius: var(--fs-radius-sm);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
border-radius: var(--radius-sm);
padding: 0.15rem 0.5rem;
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
}
.iap-btn-toggle:hover {
border-color: var(--fs-accent);
color: var(--fs-accent);
border-color: var(--color-primary);
color: var(--color-primary);
}
.iap-actions {
@@ -183,7 +183,7 @@ const markers: Record<DiffLine["type"], string> = {
.iap-btn-accept,
.iap-btn-reject {
border: none;
border-radius: var(--fs-radius-sm);
border-radius: var(--radius-sm);
padding: 0.2rem 0.65rem;
cursor: pointer;
font-size: 0.8rem;
@@ -191,19 +191,19 @@ const markers: Record<DiffLine["type"], string> = {
font-weight: var(--fs-weight-medium);
}
.iap-btn-accept {
background: var(--fs-success);
background: var(--color-success, #22c55e);
color: var(--fs-text-on-action);
}
.iap-btn-accept:hover { opacity: 0.85; }
.iap-btn-reject {
background: var(--fs-surface-raised);
color: var(--fs-text-secondary);
border: 1px solid var(--fs-border-color);
background: var(--color-bg-card, var(--color-bg));
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.iap-btn-reject:hover {
border-color: var(--fs-error);
color: var(--fs-error);
border-color: var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
}
/* ── Diff ── */
@@ -224,14 +224,14 @@ const markers: Record<DiffLine["type"], string> = {
word-break: break-word;
}
.iap-diff-equal { color: var(--fs-text-tertiary); }
.iap-diff-equal { color: var(--color-text-muted); }
.iap-diff-delete {
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
color: var(--fs-error-fg);
background: color-mix(in srgb, var(--color-danger, #e74c3c) 10%, transparent);
color: var(--color-danger, #e74c3c);
}
.iap-diff-insert {
background: color-mix(in srgb, var(--fs-success) 10%, transparent);
color: var(--fs-success-fg);
background: color-mix(in srgb, var(--color-success, #22c55e) 10%, transparent);
color: var(--color-success, #22c55e);
}
.iap-diff-marker {
@@ -243,7 +243,7 @@ const markers: Record<DiffLine["type"], string> = {
.iap-diff-empty {
padding: 0.75rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
font-size: 0.85rem;
font-family: inherit;
}
-79
View File
@@ -1,79 +0,0 @@
<script setup lang="ts">
/**
* A task's KIND, shown on a list row — issue, spike, or a legacy plan.
*
* Sibling of PriorityBadge, and shaped like it on purpose: same geometry, and
* the same rule that the DEFAULT value renders nothing. `work` is most tasks,
* so badging it would put a chip on nearly every row and say nothing — the
* same reason RuleListPane marks only `conditional`.
*
* Kind is not status. A task can be an in-progress issue or a done spike;
* this answers "what kind of work is this", never "how is it going".
*/
import type { TaskKind } from "@/types/note";
const props = defineProps<{ kind?: TaskKind | null }>();
const LABELS: Record<string, string> = {
issue: "Issue",
spike: "Spike",
plan: "Plan",
};
const TITLES: Record<string, string> = {
issue: "Corrective work — something was broken",
spike: "Time-boxed investigation — the output is an answer, not a change",
plan: "Legacy plan-task; plans are milestones now",
};
</script>
<template>
<span
v-if="props.kind && LABELS[props.kind]"
:class="['kind-badge', `kind-${props.kind}`]"
:title="TITLES[props.kind]"
>{{ LABELS[props.kind] }}</span>
</template>
<style scoped>
.kind-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
/* 500, not the 600 StatusBadge and PriorityBadge use. The house style
allows two weights, 400 and 500 — those two predate the constraint and
copying them would spread it. */
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
white-space: nowrap;
}
/* Issue and spike are opposite in character — corrective vs exploratory — so
they are split by TEMPERATURE, warm against cool, which survives being
small and stays distinguishable without relying on reading the word.
Neither uses the accent: one accent per app, and kind is not one of the
places it is allowed.
The text is the hue mixed toward --fs-text-primary rather than the raw
semantic colour. Raw fails the contrast floor on the dark palette —
measured: warning on its own 12% tint is 2.97:1, well under AA's 4.5.
Mixing toward the text token also makes these follow the mode for free,
since that token inverts. Measured both ways: issue 5.23:1 dark / 6.68:1
light, spike 5.33:1 / 9.26:1. */
.kind-issue {
background: color-mix(in srgb, var(--fs-warning) 14%, var(--fs-surface-raised));
color: color-mix(in srgb, var(--fs-warning) 60%, var(--fs-text-primary));
}
.kind-spike {
background: color-mix(in srgb, var(--fs-info) 14%, var(--fs-surface-raised));
color: color-mix(in srgb, var(--fs-info) 50%, var(--fs-text-primary));
}
/* Retired since 0066 — deliberately hue-free so a legacy row reads as
archival rather than as a fourth active kind competing for attention. */
.kind-plan {
background: var(--fs-surface-raised);
color: var(--fs-text-tertiary);
font-style: italic;
}
</style>
@@ -1,93 +0,0 @@
<script setup lang="ts">
/**
* "What was learned from this record" — the reverse of a lesson's
* `learned_from`.
*
* THE DIRECTION THAT GETS FORGOTTEN, and arguably the more useful one. The
* forward link is easy to remember because the lesson's author types it; this
* one has no author and so tends never to get built. A reader opening an old
* issue wants to know what came out of it, and without this the relation is
* navigable only from the lesson's side.
*
* A COMPONENT rather than markup inside the task editor, because the same
* question is worth answering on any record a lesson can cite — an issue, a
* spike, a dev-log. One panel, mounted wherever that is true, instead of the
* shape being written a second time the first time someone wants it on notes
* (#3207).
*
* SILENT WHEN EMPTY. Most records taught no lesson, and a panel that renders
* "None yet" on every page is a panel people learn to skip — which costs the
* pages where it does have something to say.
*/
import { onMounted, ref, watch } from "vue";
import { lessonsTaughtBy, type Lesson } from "@/api/lessons";
const props = defineProps<{ recordId: number }>();
const lessons = ref<Lesson[]>([]);
// No error surface on purpose: this is a secondary panel beside the record the
// reader actually came for, and a red box about a failed side-query would be
// louder than the thing it failed to fetch. It stays silent and stays absent.
const loaded = ref(false);
async function load() {
loaded.value = false;
lessons.value = [];
if (!props.recordId) return;
try {
const res = await lessonsTaughtBy(props.recordId);
lessons.value = res.lessons;
} catch {
lessons.value = [];
} finally {
loaded.value = true;
}
}
watch(() => props.recordId, load);
onMounted(load);
</script>
<template>
<section v-if="loaded && lessons.length" class="ltp">
<h3 class="ltp-label">What was learned from this</h3>
<ul class="ltp-list">
<li v-for="l in lessons" :key="l.id" class="ltp-item">
<router-link :to="`/lessons/${l.id}`" class="ltp-link">
{{ l.what || l.title }}
</router-link>
<!-- The trigger travels with the row. A lesson listed without it is a
claim with the half that says when it matters left off. -->
<p v-if="l.when_to_apply" class="ltp-trigger">
{{ l.when_to_apply }}
</p>
</li>
</ul>
</section>
</template>
<style scoped>
.ltp {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--fs-border-color);
}
.ltp-label {
margin: 0 0 0.6rem;
font-size: 0.72rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fs-text-tertiary);
}
.ltp-list { list-style: none; margin: 0; padding: 0; }
.ltp-item { margin-bottom: 0.7rem; }
.ltp-link { color: var(--fs-accent); font-size: 0.9rem; }
.ltp-trigger {
margin: 0.15rem 0 0;
color: var(--fs-text-secondary);
font-size: 0.82rem;
line-height: 1.45;
}
</style>
+11 -11
View File
@@ -111,9 +111,9 @@ const groups = [
align-items: center;
gap: 2px;
flex-wrap: wrap;
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 3px 4px;
}
@@ -127,7 +127,7 @@ const groups = [
display: block;
width: 1px;
height: 18px;
background: var(--fs-border-color);
background: var(--color-border);
flex-shrink: 0;
margin: 0 3px;
}
@@ -141,7 +141,7 @@ const groups = [
border: none;
border-radius: 5px;
background: transparent;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
cursor: pointer;
padding: 0;
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
@@ -149,19 +149,19 @@ const groups = [
}
.md-btn:hover {
background: var(--fs-surface-raised);
color: var(--fs-text-primary);
background: var(--color-bg-card);
color: var(--color-text);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.md-btn.active {
background: color-mix(in srgb, var(--fs-accent) 14%, transparent);
color: var(--fs-accent-fg);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--fs-accent) 35%, transparent);
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
color: var(--color-primary);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 35%, transparent);
}
.md-btn.active:hover {
background: color-mix(in srgb, var(--fs-accent) 22%, transparent);
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
}
.btn-icon {
+16 -3
View File
@@ -51,7 +51,7 @@ function onChange(e: Event) {
<template>
<select
class="fs-input milestone-select"
class="milestone-select"
:value="modelValue ?? ''"
:disabled="!projectId || loading"
@change="onChange"
@@ -64,10 +64,23 @@ function onChange(e: Event) {
</template>
<style scoped>
/* The input itself is the .fs-input canon (components.css); only the
layout remainder lives here. */
.milestone-select {
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
width: 100%;
}
.milestone-select:focus {
outline: none;
border-color: var(--color-primary);
}
.milestone-select:disabled {
opacity: 0.5;
cursor: default;
}
</style>
-190
View File
@@ -1,190 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { getProposals, judgeProposals } from "@/api/moments";
import type { MomentProposal, ProposalsPayload, RuleProposals, Verdict } from "@/api/moments";
import { apiErrorMessage } from "@/api/client";
import { useMomentsStore } from "@/stores/moments";
import { useToastStore } from "@/stores/toast";
/**
* Proposals about a rule's moments, waiting on a person. A mount proposal
* (milestone 458 step 7) comes from a pass that read the rule, or from the
* rule being opened just after the moment fired in several sessions; nothing
* is mounted until "Mount" is pressed. An unmount proposal (step 7b) comes
* from sessions reporting that the mount arrived where it did not apply;
* nothing is taken off until "Take it off" is pressed. Both are the judgment
* `judge_rule_moments` makes in a session, through the same service.
*/
const store = useMomentsStore();
const toast = useToastStore();
const data = ref<ProposalsPayload | null>(null);
const failed = ref(false);
const busy = ref<string | null>(null);
const rules = computed<RuleProposals[]>(() => data.value?.rules ?? []);
async function load() {
try {
data.value = await getProposals();
failed.value = false;
} catch {
failed.value = true;
}
}
function key(rule: RuleProposals, p: MomentProposal): string {
return `${rule.id}:${p.moment}`;
}
function plural(n: number, one: string, many: string): string {
return `${n} ${n === 1 ? one : many}`;
}
function sourceLabel(p: MomentProposal): string {
if (p.proposal === "unmount") {
return `reported as not applying here on ${plural(p.evidence.situations, "day", "days")}`;
}
if (p.source === "signal") {
return `opened just after this moment in ${p.evidence.situations} sessions`;
}
return "proposed from the rule's text";
}
// What the action that reached the moment was, most frequent first — so the
// operator can see whether it is the rule or one action that keeps misfiring.
function actions(p: MomentProposal): string[] {
return Object.entries(p.reached_by ?? {})
.sort((a, b) => b[1] - a[1])
.map(([action, n]) => `${action} ×${n}`);
}
function note(p: MomentProposal, verdict: Verdict): string {
if (p.proposal === "unmount") {
return verdict === "confirm" ? "Kept in Settings." : "Taken off in Settings.";
}
return verdict === "confirm" ? "Mounted in Settings." : "Declined in Settings.";
}
async function decide(rule: RuleProposals, p: MomentProposal, verdict: Verdict) {
busy.value = key(rule, p);
try {
const out = await judgeProposals([{
rule_id: rule.id, moment: p.moment, verdict, note: note(p, verdict),
}]);
if (out.refused.length) {
toast.show(out.refused[0].error, "error");
return;
}
await load();
// A mount or an unmount changes the per-moment counts the list below shows.
if ((p.proposal === "unmount") === (verdict === "reject")) await store.load(true);
} catch (e) {
toast.show(apiErrorMessage(e, "Could not record that"), "error");
} finally {
busy.value = null;
}
}
onMounted(load);
</script>
<template>
<section v-if="failed || rules.length" class="proposals" aria-labelledby="proposals-title">
<h3 id="proposals-title" class="sub-title">Waiting on you</h3>
<p v-if="failed" class="error-msg">
The proposals could not be loaded.
<button type="button" class="btn-text" @click="load">Try again</button>
</p>
<template v-else>
<p class="field-hint">
Moments these rules may belong on, or may not. Mounting one makes the rule arrive
whenever that moment happens, whatever the work is about; declining keeps it from being
proposed again. A mount that sessions keep reporting as beside the point can be taken
off — or, when one action is reaching the moment wrongly, unmapped in the list below.
</p>
<ul class="proposal-list">
<li v-for="rule in rules" :key="rule.id" class="proposal-rule">
<div class="rule-head">
<span class="rule-title">#{{ rule.id }} {{ rule.title }}</span>
<span v-if="rule.mounted.length" class="rule-mounted">
on <code v-for="m in rule.mounted" :key="m" class="moment-name">{{ m }}</code>
</span>
</div>
<p class="rule-statement">{{ rule.statement }}</p>
<ul class="proposal-items">
<li v-for="p in rule.proposals" :key="key(rule, p)" class="proposal-item">
<span v-if="p.proposal === 'unmount'" class="proposal-kind">take off</span>
<code class="moment-name">{{ p.moment }}</code>
<span class="proposal-why">
{{ p.why || sourceLabel(p) }}
<span v-if="p.why" class="proposal-source">· {{ sourceLabel(p) }}</span>
<span v-if="actions(p).length" class="proposal-source">
· reached by {{ actions(p).join(", ") }}
</span>
</span>
<span v-if="p.proposal === 'unmount'" class="proposal-actions">
<button
type="button" class="btn-primary btn-sm"
:disabled="busy !== null"
@click="decide(rule, p, 'reject')"
>Take it off</button>
<button
type="button" class="btn-text"
:disabled="busy !== null"
@click="decide(rule, p, 'confirm')"
>Keep it</button>
</span>
<span v-else class="proposal-actions">
<button
type="button" class="btn-primary btn-sm"
:disabled="busy !== null"
@click="decide(rule, p, 'confirm')"
>Mount</button>
<button
type="button" class="btn-text"
:disabled="busy !== null"
@click="decide(rule, p, 'reject')"
>Not this</button>
</span>
<ul v-if="p.reasons && p.reasons.length > 1" class="proposal-reasons">
<li v-for="r in p.reasons.slice(0, -1).reverse()" :key="r.at">{{ r.why }}</li>
</ul>
</li>
</ul>
</li>
</ul>
</template>
</section>
</template>
<style scoped>
.proposals { display: flex; flex-direction: column; gap: var(--fs-space-2); }
.sub-title { margin: 0; font-size: 0.95rem; color: var(--fs-text-primary); }
.proposal-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
.proposal-rule {
padding: var(--fs-space-3);
background: var(--fs-surface-page);
border: 1px dashed var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
.rule-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--fs-space-2); }
.rule-title { font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); font-weight: var(--fs-weight-medium); }
.rule-mounted { display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-1); font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); }
.rule-statement { margin: var(--fs-space-1) 0 var(--fs-space-2); font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); line-height: var(--fs-leading-body); }
.proposal-items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
.proposal-item { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--fs-space-2); font-size: var(--fs-size-body-sm); }
.proposal-why { flex: 1 1 16rem; color: var(--fs-text-secondary); }
.proposal-source { color: var(--fs-text-tertiary); font-size: var(--fs-size-tiny); }
.proposal-actions { display: inline-flex; gap: var(--fs-space-2); margin-left: auto; }
.proposal-kind { font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); }
.proposal-reasons {
flex-basis: 100%;
margin: 0;
padding-left: var(--fs-space-4);
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
}
</style>
<style src="@/assets/moments-shared.css" />
-297
View File
@@ -1,297 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useMomentsStore } from "@/stores/moments";
import MomentProposals from "@/components/MomentProposals.vue";
import type { MomentAction, MomentUsage } from "@/api/moments";
import { fmtDate } from "@/utils/dateFormat";
/**
* The human door onto moments (milestone 458 step 6): every moment in the
* catalog, the actions that reach it on this install, how many rules ride on
* it, and what reaching it has delivered. The session's own door is
* `list_moments` / `map_action` / `unmap_action`, and both call the same
* services — this view exists so a person can see and change everything an
* agent can, without asking one to.
*/
const store = useMomentsStore();
const data = computed(() => store.data);
const catalog = computed(() => data.value?.moments ?? []);
const days = computed(() => data.value?.usage.days ?? 30);
const usageFailed = computed(() => !!data.value?.usage.moment_usage_failed);
function actionsFor(moment: string): MomentAction[] {
return data.value?.actions[moment] ?? [];
}
function mounted(moment: string): number {
return data.value?.mounted[moment] ?? 0;
}
function usage(moment: string): MomentUsage | null {
return data.value?.usage.by_moment[moment] ?? null;
}
// A named procedure is its own moment (`skill.<name>`), so it appears here
// only once something is mounted on it or has been delivered at it — the
// catalog cannot list procedures it has never heard of.
const procedureMoments = computed(() => {
const names = new Set<string>([
...Object.keys(data.value?.mounted ?? {}),
...Object.keys(data.value?.usage.by_moment ?? {}),
]);
return [...names].filter((n) => n.startsWith("skill.")).sort();
});
function actionLabel(a: { tool: string; match: string }): string {
return a.match ? `${a.tool} · ${a.match}` : a.tool;
}
const busy = ref(false);
async function run(fn: () => Promise<unknown>) {
busy.value = true;
try {
await fn();
} catch {
// The store has already said why; the row stays as it was.
} finally {
busy.value = false;
}
}
function removeAction(moment: string, a: MomentAction) {
// A shipped default is switched off rather than deleted, and comes back
// with "Restore" below — worth a confirm, since every later session on
// this install loses the moment for that action.
if (a.via === "default"
&& !confirm(`Stop ${actionLabel(a)} reaching ${moment} on this install? You can restore it below.`)) {
return;
}
void run(() => store.unmap({ tool: a.tool, match: a.match, moment, reason: "Removed in Settings." }));
}
function restore(r: { tool: string; match: string; moment: string }) {
void run(() => store.map({ tool: r.tool, match: r.match, moment: r.moment }));
}
// ── Add a mapping ─────────────────────────────────────────────────────────
const draftTool = ref("");
const draftMatch = ref("");
const draftMoment = ref("");
const draftReason = ref("");
const canAdd = computed(() => !!draftTool.value.trim() && !!draftMoment.value && !busy.value);
async function addMapping() {
if (!canAdd.value) return;
busy.value = true;
try {
await store.map({
tool: draftTool.value.trim(),
match: draftMatch.value.trim(),
moment: draftMoment.value,
reason: draftReason.value.trim(),
});
draftTool.value = "";
draftMatch.value = "";
draftReason.value = "";
} catch {
// Kept as typed, so the refusal the store showed can be acted on.
} finally {
busy.value = false;
}
}
onMounted(() => store.load(true));
</script>
<template>
<div class="moments-settings">
<p v-if="store.loading && !data" class="empty-msg">Loading moments…</p>
<p v-else-if="store.failed && !data" class="error-msg">
The moment catalog could not be loaded.
<button type="button" class="btn-text" @click="store.load(true)">Try again</button>
</p>
<template v-else-if="data">
<p class="window-note">
Deliveries and opens over the last {{ days }} days. “Opened” counts the rules an agent
went on to read — an upper bound, since a rule delivered at two moments and read once
counts at both.
</p>
<p v-if="usageFailed" class="error-msg">
The delivery counts could not be read, so they are missing below — not zero.
</p>
<p v-if="data.mounted_failed" class="error-msg">
The mount counts could not be read, so they are missing below — not zero.
</p>
<MomentProposals />
<ul class="moment-list">
<li v-for="m in catalog" :key="m.name" class="moment-row">
<div class="moment-head">
<code class="moment-name">{{ m.name }}</code>
<span class="moment-stats">
<span :class="{ 'stat-none': !mounted(m.name) }">
{{ mounted(m.name) ? `${mounted(m.name)} ${mounted(m.name) === 1 ? "rule" : "rules"} mounted` : "nothing mounted" }}
</span>
<template v-if="usage(m.name)">
<span>delivered {{ usage(m.name)!.delivered }}</span>
<span>opened {{ usage(m.name)!.opened }} of {{ usage(m.name)!.rules }}</span>
<span v-if="usage(m.name)!.last_delivered_at" class="stat-when">
last {{ fmtDate(usage(m.name)!.last_delivered_at!) }}
</span>
</template>
</span>
</div>
<p class="moment-desc">{{ m.means }}</p>
<div class="moment-actions">
<span class="actions-label">Reached by</span>
<span v-if="!actionsFor(m.name).length" class="stat-none">
no action here — {{ m.reached_by }}
</span>
<span
v-for="a in actionsFor(m.name)"
:key="`${a.via}:${a.tool}:${a.match}`"
:class="['action-chip', { 'is-install': a.via === 'install' }]"
:title="a.via === 'install' ? 'Added on this install' : 'Ships with Scribe'"
>
<code>{{ actionLabel(a) }}</code>
<button
type="button"
class="chip-remove"
:disabled="busy"
:aria-label="a.via === 'install'
? `Remove ${actionLabel(a)} from ${m.name}`
: `Switch off the shipped ${actionLabel(a)} for ${m.name}`"
@click="removeAction(m.name, a)"
>×</button>
</span>
</div>
</li>
</ul>
<template v-if="procedureMoments.length">
<h3 class="sub-title">Named procedures</h3>
<ul class="moment-list">
<li v-for="name in procedureMoments" :key="name" class="moment-row">
<div class="moment-head">
<code class="moment-name">{{ name }}</code>
<span class="moment-stats">
<span :class="{ 'stat-none': !mounted(name) }">
{{ mounted(name) ? `${mounted(name)} ${mounted(name) === 1 ? "rule" : "rules"} mounted` : "nothing mounted" }}
</span>
<template v-if="usage(name)">
<span>delivered {{ usage(name)!.delivered }}</span>
<span>opened {{ usage(name)!.opened }} of {{ usage(name)!.rules }}</span>
</template>
</span>
</div>
</li>
</ul>
</template>
<template v-if="data.removed_defaults.length">
<h3 class="sub-title">Shipped mappings switched off here</h3>
<ul class="removed-list">
<li v-for="r in data.removed_defaults" :key="r.id" class="removed-row">
<code>{{ actionLabel(r) }}</code>
<span class="removed-arrow">→</span>
<code>{{ r.moment }}</code>
<span v-if="r.reason" class="removed-reason">{{ r.reason }}</span>
<button type="button" class="btn-text" :disabled="busy" @click="restore(r)">Restore</button>
</li>
</ul>
</template>
<form class="add-mapping" @submit.prevent="addMapping">
<h3 class="sub-title">Add an action</h3>
<p class="field-hint">
When something you do plainly happens at a moment and nothing fired — your own deploy
script, a tool from another MCP server. For a command tool, the match is how the command
starts (<code>make ship</code>); for any other tool, its arguments as
<code>field=value</code> pairs. Leave it empty to map every call.
</p>
<div class="add-grid">
<label>
Tool
<input v-model="draftTool" class="fs-input" placeholder="Bash, or an MCP tool's name" />
</label>
<label>
Match
<input v-model="draftMatch" class="fs-input" placeholder="make ship · status=done · empty for every call" />
</label>
<label>
Moment
<select v-model="draftMoment" class="fs-input">
<option value="" disabled>Choose a moment</option>
<option v-for="m in catalog" :key="m.name" :value="m.name">{{ m.name }}</option>
</select>
</label>
<label>
Why <span class="optional">(optional)</span>
<input v-model="draftReason" class="fs-input" placeholder="what it is for here" />
</label>
</div>
<button type="submit" class="btn-primary" :disabled="!canAdd">Add action</button>
</form>
</template>
</div>
</template>
<style scoped>
.moments-settings { display: flex; flex-direction: column; gap: var(--fs-space-3); }
.window-note { margin: 0; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); line-height: var(--fs-leading-body); }
.moment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
.moment-row {
padding: var(--fs-space-3);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
.moment-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--fs-space-2); }
.moment-head .moment-name { font-size: var(--fs-size-body-sm); }
.moment-stats {
margin-left: auto;
display: flex; flex-wrap: wrap; gap: var(--fs-space-3);
font-size: var(--fs-size-tiny); color: var(--fs-text-secondary);
font-variant-numeric: tabular-nums;
}
/* Nothing mounted is the ordinary state of most moments, not a fault. */
.stat-none { color: var(--fs-text-tertiary); font-style: italic; }
.stat-when { color: var(--fs-text-tertiary); }
.moment-desc { margin: var(--fs-space-1) 0 var(--fs-space-2); font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); line-height: var(--fs-leading-body); }
.moment-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-2); font-size: var(--fs-size-tiny); }
.actions-label { color: var(--fs-text-tertiary); text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny); }
.action-chip {
display: inline-flex; align-items: center; gap: var(--fs-space-1);
padding: 0.05rem 0.45rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
color: var(--fs-text-secondary);
}
.action-chip code { font-family: var(--fs-font-mono); }
/* This install's own, set apart from the shipped ones the way the tuning
trail marks the operator's changes: a dashed edge, no accent. */
.action-chip.is-install { border-style: dashed; color: var(--fs-text-primary); }
.sub-title { margin: var(--fs-space-3) 0 var(--fs-space-1); font-size: 0.95rem; color: var(--fs-text-primary); }
.removed-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-1); }
.removed-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--fs-space-2); font-size: var(--fs-size-body-sm); }
.removed-row code { font-family: var(--fs-font-mono); color: var(--fs-text-secondary); text-decoration: line-through; }
.removed-arrow { color: var(--fs-text-tertiary); }
.removed-reason { color: var(--fs-text-tertiary); font-size: var(--fs-size-tiny); }
.add-mapping { display: flex; flex-direction: column; gap: var(--fs-space-2); align-items: flex-start; }
.add-grid {
width: 100%;
display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: var(--fs-space-2) var(--fs-space-3);
}
.add-grid label { display: flex; flex-direction: column; gap: var(--fs-space-1); font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); }
.optional { color: var(--fs-text-tertiary); font-weight: normal; }
</style>
<style src="@/assets/moments-shared.css" />
+17 -17
View File
@@ -60,15 +60,15 @@ function goEdit() {
.note-card {
display: block;
padding: 1rem;
border-radius: var(--fs-radius-lg);
border-radius: var(--radius-md);
text-decoration: none;
color: inherit;
background: var(--fs-surface-raised);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 6%, transparent);
background: var(--color-bg-card);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
transition: box-shadow 0.2s, transform 0.18s ease;
}
.note-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 14.0%, transparent);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
transform: translateY(-2px);
}
@@ -78,18 +78,18 @@ function goEdit() {
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.75rem;
background: var(--fs-surface-raised);
background: var(--color-bg-card);
box-shadow: none;
border-radius: 0;
border-bottom: 1px solid var(--fs-border-color);
border-bottom: 1px solid var(--color-border);
transform: none !important;
}
.note-card.compact:first-child {
border-top: 1px solid var(--fs-border-color);
border-top: 1px solid var(--color-border);
}
.note-card.compact:hover {
box-shadow: none;
background: color-mix(in srgb, var(--fs-accent) 4%, transparent);
background: rgba(91, 74, 138, 0.04);
transform: none;
}
.note-title-compact {
@@ -108,7 +108,7 @@ function goEdit() {
}
.timestamp-compact {
font-size: 0.72rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
flex-shrink: 0;
white-space: nowrap;
}
@@ -133,20 +133,20 @@ function goEdit() {
flex-shrink: 0;
padding: 0.25rem 0.6rem;
font-size: 0.8rem;
background: var(--fs-surface-raised);
color: var(--fs-text-secondary);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--color-bg-card);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.btn-edit:hover {
color: var(--fs-accent);
border-color: var(--fs-accent);
color: var(--color-primary);
border-color: var(--color-primary);
}
.note-preview {
margin: 0 0 0.5rem;
color: var(--fs-text-secondary);
color: var(--color-text-secondary);
font-size: 0.9rem;
max-height: 7.5em;
overflow: hidden;
@@ -163,6 +163,6 @@ function goEdit() {
.timestamp {
margin-left: auto;
font-size: 0.75rem;
color: var(--fs-text-tertiary);
color: var(--color-text-muted);
}
</style>

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