Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bf55fc488 |
+16
-102
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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)")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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", {});
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
@@ -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`);
|
||||
|
||||
@@ -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}` : ""}`);
|
||||
}
|
||||
@@ -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 });
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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-<date>.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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 & Release, Auth & Access, Data Model & 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
@@ -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" />
|
||||
@@ -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
Reference in New Issue
Block a user