Compare commits
+74
-186
@@ -4,27 +4,15 @@
|
|||||||
# Push to main: typecheck + lint + test + build :latest + :<sha>
|
# Push to main: typecheck + lint + test + build :latest + :<sha>
|
||||||
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
|
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
|
||||||
#
|
#
|
||||||
# Both dev and main are gated AND built. dev pushes move :dev; main pushes move
|
# main is the production line, so :latest tracks main's tip. Every push also
|
||||||
# :latest — main IS the production line, so :latest tracks main's tip and there
|
# gets an immutable :<sha>, the rollback point. A release (a v* tag created on
|
||||||
# is no separate :main tag. Every push also gets an immutable :<sha> (the
|
# main in the Forgejo UI) adds the dated :<version>.
|
||||||
# rollback point). A v* release tag additionally publishes the dated :<version>;
|
|
||||||
# since main already moved :latest, the release tag's distinct job is that
|
|
||||||
# :<version> marker (it refreshes :latest too, harmlessly).
|
|
||||||
#
|
#
|
||||||
# Successive pushes to the SAME ref supersede each other (see concurrency
|
# A newer push to the same ref cancels the older run; dev and main never cancel
|
||||||
# below), so rapid pushes don't stack identical work; dev and main runs are
|
# each other. Pull requests don't trigger runs: branch pushes already gate.
|
||||||
# independent refs and never cancel one another.
|
|
||||||
#
|
#
|
||||||
# To cut a release:
|
# Forgejo Actions doesn't reliably honour `on.push.branches` as a filter, so
|
||||||
# Create a release via the Forgejo UI on main with a v* tag name.
|
# every job repeats the ref check in its `if:`.
|
||||||
# The tag push triggers this workflow; build job pushes :latest + :<version>.
|
|
||||||
#
|
|
||||||
# PRs aren't triggered on purpose — this is a solo dev→main flow, so
|
|
||||||
# gating on branch push is already enough.
|
|
||||||
#
|
|
||||||
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
|
|
||||||
# honor `on.push.branches` as a filter, so every job repeats the ref check
|
|
||||||
# explicitly — permitting dev, main, and v* tags, rejecting anything else.
|
|
||||||
#
|
#
|
||||||
# Required secrets (repo → Settings → Secrets → Actions):
|
# Required secrets (repo → Settings → Secrets → Actions):
|
||||||
# REGISTRY_USER — your Forgejo username
|
# REGISTRY_USER — your Forgejo username
|
||||||
@@ -40,31 +28,22 @@ on:
|
|||||||
- "frontend/**"
|
- "frontend/**"
|
||||||
- "tests/**"
|
- "tests/**"
|
||||||
- "pyproject.toml"
|
- "pyproject.toml"
|
||||||
# The lock now determines what gets installed, so a lock-only change has
|
# The lock decides what is installed, so a lock-only change must run CI.
|
||||||
# to trigger a run — otherwise a dependency bump lands untested.
|
|
||||||
- "uv.lock"
|
- "uv.lock"
|
||||||
- "alembic/**"
|
- "alembic/**"
|
||||||
- "alembic.ini"
|
- "alembic.ini"
|
||||||
- "Dockerfile"
|
- "Dockerfile"
|
||||||
# The plugin ships straight from this repo — installs fetch it via
|
# The plugin ships straight from git (via .claude-plugin/marketplace.json),
|
||||||
# .claude-plugin/marketplace.json, NOT from the image. So a push here is
|
# not in the image, so a push here is its release. See the `plugin` job.
|
||||||
# the release, with no build step in between. Omitting these paths meant
|
|
||||||
# plugin changes triggered no workflow at all, which is how #2198's three
|
|
||||||
# broken hooks and then #2209's missing version bump both reached a live
|
|
||||||
# install. See the `plugin` job below.
|
|
||||||
- "plugin/**"
|
- "plugin/**"
|
||||||
- ".claude-plugin/**"
|
- ".claude-plugin/**"
|
||||||
- "scripts/check_plugin.py"
|
- "scripts/check_plugin.py"
|
||||||
- ".forgejo/workflows/ci.yml"
|
- ".forgejo/workflows/ci.yml"
|
||||||
# Manual trigger from the Forgejo Actions UI. Useful when an image has
|
# Manual run from the Forgejo Actions UI, e.g. to rebuild an image.
|
||||||
# been built but the deployment didn't pick it up, or when re-running
|
|
||||||
# against the same source produces different upstream behaviour
|
|
||||||
# (e.g. a transient HF download flake during the voice-bundle step).
|
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
# Cancel older runs on the same branch when a newer push lands. Tag runs
|
# A newer push cancels the older run on the same branch. Tag runs are never
|
||||||
# get their own group implicitly (refs/tags/v1.2.3 ≠ refs/heads/dev) and
|
# cancelled, so a release build can't be cut off mid-flight.
|
||||||
# are never cancelled, so a release build can't kill itself mid-flight.
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||||
@@ -91,10 +70,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Cache npm download cache
|
- name: Cache npm download cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
# Non-fatal: a transient cache-backend hiccup must NOT fail the whole
|
# Non-fatal: a cache-backend error just means installing without it.
|
||||||
# typecheck job (it was skipping install + type check and reporting red
|
|
||||||
# on backend-only pushes — see issue task #828). On cache miss/error the
|
|
||||||
# job just installs without the cache.
|
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
@@ -110,8 +86,7 @@ jobs:
|
|||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
# Guards the one part of this repo that ships to users without a build step.
|
# 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
|
# scripts/check_plugin.py says what it checks and what it can't.
|
||||||
# can't check yet (shellcheck is absent from ci-python).
|
|
||||||
plugin:
|
plugin:
|
||||||
name: Plugin hooks
|
name: Plugin hooks
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||||
@@ -119,25 +94,14 @@ jobs:
|
|||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
steps:
|
steps:
|
||||||
# Bare `uses:`, no `with:` block. Adding one made this action fail to
|
# Keep this a bare `uses:`: with a `with:` block the action fails to
|
||||||
# extract on the act_runner ("Cannot find module .../dist/index.js") while
|
# extract on act_runner ("Cannot find module .../dist/index.js"). The
|
||||||
# every bare checkout in the same run succeeded — see run 3027. Nothing
|
# version check diffs two trees, so a depth-1 fetch of main is enough.
|
||||||
# here needs `fetch-depth: 0` anyway: the version check diffs two trees,
|
|
||||||
# and a tree diff needs both trees, not a common ancestor. A depth-1 fetch
|
|
||||||
# of main's tip is enough, and cheaper.
|
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
# Per-job, not in the image, per CI-runner's docs/process.md: "If only one
|
# Per-job rather than in the image: Scribe is shellcheck's only consumer.
|
||||||
# project needs the dep, prefer that project installing it per-job in
|
# Install nothing else here; the hooks use only POSIX tools, and the
|
||||||
# their workflow — at least until a second consumer arrives." Scribe is
|
# smoke test runs on a bare image to prove it.
|
||||||
# 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.
|
|
||||||
- name: Install shell tooling
|
- name: Install shell tooling
|
||||||
run: |
|
run: |
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
@@ -163,28 +127,19 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
# ruff is pre-installed in the ci-python image — no install
|
# ruff is pre-installed in the ci-python image.
|
||||||
# step needed, lint runs in ~2s.
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: ruff check src/ scripts/
|
run: ruff check src/ scripts/
|
||||||
|
|
||||||
# Design tokens: does the frontend's CSS agree with the stylesheet the
|
# Checks the frontend's CSS against the committed, generated theme.css.
|
||||||
# design system generates? Fails only on an unresolvable var() reference —
|
# Fails only on an unresolvable var() reference; hard-coded literals are
|
||||||
# that count is at zero, so this is a ratchet rather than a backlog. The
|
# reported, not gated.
|
||||||
# literal findings are printed, not gated; hundreds exist and a
|
|
||||||
# permanently-red job is one nobody reads.
|
|
||||||
#
|
|
||||||
# Stdlib only, no install, no network: the source of truth is theme.css,
|
|
||||||
# which is generated from the design system and committed.
|
|
||||||
- name: Design token check
|
- name: Design token check
|
||||||
run: python3 scripts/check_design_tokens.py --report-literals
|
run: python3 scripts/check_design_tokens.py --report-literals
|
||||||
|
|
||||||
# Dangling styles: an element whose classes have only modifier rules and
|
# Reports elements whose classes have modifier rules but no base rule,
|
||||||
# no base — a deleted CSS rule that left its `:hover` behind. Two shipped
|
# which vue-tsc can't see. Not gated: a bare wrapper is legitimate, so
|
||||||
# this way (a link rendering as raw browser blue, a flex row whose parent
|
# the signal is the count growing.
|
||||||
# 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
|
- name: Dangling style check
|
||||||
run: python3 scripts/check_dangling_styles.py
|
run: python3 scripts/check_dangling_styles.py
|
||||||
|
|
||||||
@@ -201,63 +156,41 @@ jobs:
|
|||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/uv
|
path: ~/.cache/uv
|
||||||
# Keyed on the LOCK, not pyproject: the lock is what determines the
|
# Keyed on the lock, which decides the installed set.
|
||||||
# installed set now, and a pyproject edit that doesn't change
|
|
||||||
# resolution shouldn't throw the cache away.
|
|
||||||
key: uv-${{ hashFiles('uv.lock') }}
|
key: uv-${{ hashFiles('uv.lock') }}
|
||||||
restore-keys: uv-
|
restore-keys: uv-
|
||||||
|
|
||||||
# Installs exactly what uv.lock pins, and resolves nothing itself.
|
# Installs exactly what uv.lock pins, so green means these exact versions
|
||||||
#
|
# passed. `--locked` fails if the lock is stale against pyproject, so a
|
||||||
# This replaced `uv pip install -e ".[dev]"`, which resolved from the
|
# dependency change needs a deliberate `uv lock`.
|
||||||
# pyproject constraints and ignored the lock entirely. Every dependency
|
|
||||||
# floated: on 2026-07-28 mcp 2.0.0 shipped mid-session and turned `main`
|
|
||||||
# red with no repo change (issue #2194). Green CI has to mean "these exact
|
|
||||||
# versions passed", or it isn't evidence of anything.
|
|
||||||
#
|
|
||||||
# `--locked` also FAILS when uv.lock is stale against pyproject, so a
|
|
||||||
# dependency edit has to go through a deliberate `uv lock` — it can't
|
|
||||||
# arrive on its own. That check earned its place immediately: it caught
|
|
||||||
# that the lock had been missing `pgvector` entirely (added to pyproject,
|
|
||||||
# never re-locked), which the old install path had been silently papering
|
|
||||||
# over by resolving from pyproject instead.
|
|
||||||
- name: Install locked dependencies
|
- name: Install locked dependencies
|
||||||
env:
|
env:
|
||||||
UV_PROJECT_ENVIRONMENT: /opt/venv
|
UV_PROJECT_ENVIRONMENT: /opt/venv
|
||||||
run: uv sync --locked --extra dev
|
run: uv sync --locked --extra dev
|
||||||
|
|
||||||
# An "Install jq for hook execution tests" step stood here. The hook
|
# The hook execution tests run on a bare image (no jq), like a user's
|
||||||
# EXECUTION tests run the real bash hook, which exited silently without
|
# machine might be.
|
||||||
# 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
|
- name: Run tests
|
||||||
# Integration tests (real Postgres) run in the `integration` job below.
|
# Integration tests (real Postgres) run in the `integration` job below.
|
||||||
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
|
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
|
||||||
|
|
||||||
# Real-Postgres lane (family rule 6). Exercises the async SQLAlchemy connection
|
# Real Postgres, built by the real migrations: covers the async SQLAlchemy
|
||||||
# path the unit stubs can't reach — the un-awaited execution_options regression
|
# paths unit stubs can't reach.
|
||||||
# that made every VACUUM report 0/6 lived here. Like `test`, it runs for
|
|
||||||
# visibility and does NOT gate the build.
|
|
||||||
#
|
#
|
||||||
# Job key stays separator-free ("integration"): act_runner derives the service-
|
# Service hostnames aren't routable on this runner, so the step finds the
|
||||||
# container name from the (truncated) job display name and the discovery step
|
# Postgres container's bridge IP. The job has no `name:` and a separator-free
|
||||||
# filters `docker ps` by it. Service hostnames aren't routable on this runner,
|
# key because act_runner names service containers after it.
|
||||||
# so the step resolves the Postgres container's bridge IP. No `name:` on purpose.
|
|
||||||
integration:
|
integration:
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
env:
|
env:
|
||||||
# Config + the module engine read these at import time. DATABASE_URL itself
|
# Read at import time. DATABASE_URL is built from the discovered IP below.
|
||||||
# is built from the discovered service IP in the run step.
|
|
||||||
SECRET_KEY: ci_integration_placeholder
|
SECRET_KEY: ci_integration_placeholder
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
# pgvector image so `alembic upgrade head` can run migration 0067
|
# Same image as the shipped compose files: Postgres 17 with pgvector.
|
||||||
# (CREATE EXTENSION vector). PG17 — matches the prod/quickstart image.
|
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: scribe
|
POSTGRES_USER: scribe
|
||||||
@@ -270,25 +203,19 @@ jobs:
|
|||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
# Same locked install as the unit lane — the two must agree on versions,
|
# Same locked install as the unit lane, so both test the same versions.
|
||||||
# or "unit green, integration red" stops being a signal about the code.
|
|
||||||
- name: Install locked dependencies
|
- name: Install locked dependencies
|
||||||
env:
|
env:
|
||||||
UV_PROJECT_ENVIRONMENT: /opt/venv
|
UV_PROJECT_ENVIRONMENT: /opt/venv
|
||||||
run: uv sync --locked --extra dev
|
run: uv sync --locked --extra dev
|
||||||
# Standing answers to the checks carried by rules 81 and 79 — two facts
|
# Prints two facts about the runner that the steps below depend on.
|
||||||
# about THIS runner that conditional rules assert as fact, and that
|
# Every command is guarded so the diagnostic can't fail the lane.
|
||||||
# otherwise need a throwaway job to confirm (#3237). Printing them on
|
- name: Runner facts (shell, service hostnames)
|
||||||
# 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: |
|
run: |
|
||||||
echo "--- rule 81: which shell runs a run: step ---"
|
echo "--- which shell runs a run: step ---"
|
||||||
readlink -f /bin/sh || echo "/bin/sh: not a symlink"
|
readlink -f /bin/sh || echo "/bin/sh: not a symlink"
|
||||||
ps -p $$ -o comm= || true
|
ps -p $$ -o comm= || true
|
||||||
echo "--- rule 79: is a service reachable by its hostname yet? ---"
|
echo "--- is a service reachable by its hostname? ---"
|
||||||
getent hosts postgres \
|
getent hosts postgres \
|
||||||
|| echo "no — 'postgres' does not resolve; the bridge-IP lookup is still required"
|
|| echo "no — 'postgres' does not resolve; the bridge-IP lookup is still required"
|
||||||
- name: Integration suite (resolve service IP, migrate, test)
|
- name: Integration suite (resolve service IP, migrate, test)
|
||||||
@@ -296,12 +223,10 @@ jobs:
|
|||||||
set -eux
|
set -eux
|
||||||
echo "=== container landscape (diagnostic for the name filter) ==="
|
echo "=== container landscape (diagnostic for the name filter) ==="
|
||||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||||
# Only THIS job's services. The runner's docker daemon is shared, so another
|
# Only this job's services: the docker daemon is shared, so a job-name
|
||||||
# repo's job can be running beside this one, and a job-name filter matches its
|
# filter can match another repo's Postgres. act names a task's containers
|
||||||
# containers too (Steward run 8358 and Inkwell run 8653 each took another repo's
|
# GITEA-ACTIONS-TASK-<n>-...; read <n> from our own container (its id is in
|
||||||
# Postgres). act names every container of a task GITEA-ACTIONS-TASK-<n>-...: read
|
# the /etc/hostname bind mount) and require exactly one match.
|
||||||
# <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=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true)
|
||||||
SELF=${SELF:-$(hostname)}
|
SELF=${SELF:-$(hostname)}
|
||||||
TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-')
|
TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-')
|
||||||
@@ -311,9 +236,7 @@ jobs:
|
|||||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||||
test -n "$PG_IP"
|
test -n "$PG_IP"
|
||||||
export DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test"
|
export DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test"
|
||||||
# Wait for Postgres to accept connections. The run: shell is dash
|
# Wait for Postgres. The run: shell is dash (no bash /dev/tcp), so use Python.
|
||||||
# (/bin/sh -> /usr/bin/dash on this Debian-based image, confirmed by
|
|
||||||
# the step above) — no bash /dev/tcp, so use Python.
|
|
||||||
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
||||||
import socket, sys, time
|
import socket, sys, time
|
||||||
for _ in range(30):
|
for _ in range(30):
|
||||||
@@ -332,21 +255,12 @@ jobs:
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build & push image
|
name: Build & push image
|
||||||
# `plugin` is deliberately NOT in needs. The plugin isn't in the image —
|
# `plugin` is not in needs: the plugin ships from git, not in the image, so
|
||||||
# installs fetch it from git — so gating the server image on a hook lint
|
# holding the image back wouldn't stop a bad hook. A red `plugin` job still
|
||||||
# would couple two things that don't ship together, and blocking the build
|
# turns the run red. `integration` is: the image carries the migrations and
|
||||||
# wouldn't un-publish a bad hook anyway: the push already did that. A failed
|
# queries that lane tests.
|
||||||
# `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, integration]
|
||||||
# Build on dev, main, and v* tag pushes. dev → :dev, main → :latest,
|
# dev → :dev, main → :latest, tag → :latest + :<version>; all get :<sha>.
|
||||||
# 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')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||||
runs-on: python-ci
|
runs-on: python-ci
|
||||||
container:
|
container:
|
||||||
@@ -357,53 +271,37 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
# Rule 149 asks for this on any job deriving the version NAME. The
|
# Full history for the version derivation. Today it needs only HEAD's
|
||||||
# name here comes from HEAD's commit TIME, which a depth-1 clone
|
# commit time, but a shallow clone fails silently (a too-low value) if
|
||||||
# already has — but the rule states it unconditionally because the
|
# the derivation ever reads further back.
|
||||||
# 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
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Generate image tags and version
|
- name: Generate image tags and version
|
||||||
id: tags
|
id: tags
|
||||||
# POSIX `case` instead of bash `[[ ]]` because act_runner invokes
|
# POSIX `case`, not bash `[[ ]]`: the run: shell is dash, where `[[`
|
||||||
# `sh -e` (dash on the ci-python:3.14 image, which has no bash on
|
# fails silently and only the :<sha> tag would be pushed.
|
||||||
# the default PATH for /bin/sh). Previous `[[ ]]` form failed
|
|
||||||
# silently — only the SHA tag got appended, so :dev / :latest
|
|
||||||
# never updated in the registry and the deployed stack kept
|
|
||||||
# pulling stale images. Verified via `[[: not found` lines in
|
|
||||||
# the runner log on commit 2a374d9.
|
|
||||||
run: |
|
run: |
|
||||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
||||||
|
|
||||||
# THREE VALUES, NEVER FOLDED TOGETHER (rule 149). Until 2026-08-31
|
# Three values kept separate; the Dockerfile describes each.
|
||||||
# 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
|
# 1. Ordering key: build time in minutes since 2020-01-01, so it only
|
||||||
# since 2020-01-01. Never a commit count (not monotonic across
|
# goes up. Commit time would go down when an older commit is
|
||||||
# branches) and never commit time (goes DOWN when an older
|
# rebuilt; a commit count isn't monotonic across branches.
|
||||||
# commit is rebuilt).
|
|
||||||
BUILD_KEY=$(( ( $(date -u +%s) - 1577836800 ) / 60 ))
|
BUILD_KEY=$(( ( $(date -u +%s) - 1577836800 ) / 60 ))
|
||||||
|
|
||||||
# 2. NAME — COMMIT time, so the same source reports the same string
|
# 2. Name: commit time, so the same source reads the same on every lane.
|
||||||
# on every lane and the channel is the only thing that differs.
|
|
||||||
COMMIT_TS=$(git log --format=%ct -1 HEAD)
|
COMMIT_TS=$(git log --format=%ct -1 HEAD)
|
||||||
BUILD_NAME=$(date -u -d "@$COMMIT_TS" +%Y.%m.%d.%H%M)
|
BUILD_NAME=$(date -u -d "@$COMMIT_TS" +%Y.%m.%d.%H%M)
|
||||||
|
|
||||||
# 3. CHANNEL — its own value. Never a suffix, never a segment.
|
# 3. Channel: its own value, never part of the name.
|
||||||
CHANNEL="dev"
|
CHANNEL="dev"
|
||||||
case "${{ github.ref }}" in
|
case "${{ github.ref }}" in
|
||||||
refs/heads/dev)
|
refs/heads/dev)
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||||
;;
|
;;
|
||||||
refs/heads/main)
|
refs/heads/main)
|
||||||
# main IS the production line: publish :latest (plus the :<sha>
|
# main is the production line, so it publishes :latest.
|
||||||
# set above). No separate :main tag.
|
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
||||||
CHANNEL="stable"
|
CHANNEL="stable"
|
||||||
;;
|
;;
|
||||||
@@ -418,12 +316,8 @@ jobs:
|
|||||||
echo "channel=$CHANNEL" >> $GITHUB_OUTPUT
|
echo "channel=$CHANNEL" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Free disk space
|
- name: Free disk space
|
||||||
# Self-hosted runner housekeeping. Two-step cleanup:
|
# Prunes unused containers and images, and caps the BuildKit cache at
|
||||||
# 1. Prune dangling containers/images globally (stops the runner
|
# 5 GB so the package caches survive but old layers don't pile up.
|
||||||
# from accumulating cruft from past failed builds).
|
|
||||||
# 2. Trim the BuildKit layer cache to a 5GB ceiling so the pip
|
|
||||||
# mount cache survives but old intermediate layers don't
|
|
||||||
# accumulate indefinitely.
|
|
||||||
run: |
|
run: |
|
||||||
docker system prune -af || true
|
docker system prune -af || true
|
||||||
docker builder prune --keep-storage 5g -f || true
|
docker builder prune --keep-storage 5g -f || true
|
||||||
@@ -445,19 +339,13 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
provenance: false
|
provenance: false
|
||||||
tags: ${{ steps.tags.outputs.value }}
|
tags: ${{ steps.tags.outputs.value }}
|
||||||
# All three, plus the commit — rule 145: the registry's identity for
|
# The commit lets the image's self-report be checked against :<sha>.
|
||||||
# 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-args: |
|
||||||
BUILD_VERSION=${{ steps.tags.outputs.build_name }}
|
BUILD_VERSION=${{ steps.tags.outputs.build_name }}
|
||||||
BUILD_KEY=${{ steps.tags.outputs.build_key }}
|
BUILD_KEY=${{ steps.tags.outputs.build_key }}
|
||||||
BUILD_CHANNEL=${{ steps.tags.outputs.channel }}
|
BUILD_CHANNEL=${{ steps.tags.outputs.channel }}
|
||||||
BUILD_COMMIT=${{ github.sha }}
|
BUILD_COMMIT=${{ github.sha }}
|
||||||
# Registry-backed layer cache. Pull from :cache to prime
|
# Layer cache kept in the registry, so a build starts warm even after
|
||||||
# BuildKit, push updated layers back to :cache so the next
|
# the runner prunes. `mode=max` caches intermediate layers too.
|
||||||
# build starts warm even if the runner's local cache was
|
|
||||||
# pruned. `mode=max` exports all intermediate layers, not
|
|
||||||
# just the final image, which is what gives the ~80% speedup.
|
|
||||||
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
|
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
|
||||||
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true
|
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true
|
||||||
|
|||||||
+11
-20
@@ -12,11 +12,8 @@ RUN npm run build
|
|||||||
FROM python:3.14-slim AS runtime
|
FROM python:3.14-slim AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Installed from uv.lock, exactly like CI (issue #2194). This used to be
|
# Installed from uv.lock, exactly like CI, so the image runs the dependency
|
||||||
# `COPY pyproject.toml .` + `pip install .`, which never even copied the lock:
|
# versions CI tested.
|
||||||
# the shipped image resolved its own dependency set, so CI could be green on one
|
|
||||||
# set of versions while the published image ran another. On 2026-07-28 that
|
|
||||||
# class of drift turned `main` red when mcp 2.0.0 shipped mid-session.
|
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
pip install --no-cache-dir uv
|
pip install --no-cache-dir uv
|
||||||
|
|
||||||
@@ -41,21 +38,15 @@ COPY alembic/ alembic/
|
|||||||
# Ensure Python finds the source tree (where static files live) before site-packages
|
# Ensure Python finds the source tree (where static files live) before site-packages
|
||||||
ENV PYTHONPATH=/app/src
|
ENV PYTHONPATH=/app/src
|
||||||
|
|
||||||
# THREE VALUES, NEVER FOLDED TOGETHER (rule 149), plus the commit.
|
# Three values kept separate, plus the commit:
|
||||||
#
|
# BUILD_VERSION is the name (YYYY.MM.DD.HHMM from commit time), the same on
|
||||||
# BUILD_VERSION is the NAME (YYYY.MM.DD.HHMM, from COMMIT time) — the same
|
# every lane for the same source.
|
||||||
# string on every lane for the same source, so it answers "is this the same
|
# BUILD_KEY is the ordering key (minutes since 2020-01-01 from build time), the
|
||||||
# code?" rather than "which lane built it?".
|
# only value compared to decide which build is newer.
|
||||||
# BUILD_KEY is the ORDERING KEY (minutes since 2020-01-01, from BUILD time) —
|
# BUILD_CHANNEL is its own field, never part of the name.
|
||||||
# the only value anything may compare to decide what is newer.
|
# BUILD_COMMIT lets the image's self-report be checked against its :<sha> tag.
|
||||||
# BUILD_CHANNEL is its own field. Never a suffix, never a segment of the name.
|
# A local build leaves the key and channel empty: it has no place in an update
|
||||||
# BUILD_COMMIT lets the artifact's self-report be checked against the :<sha>
|
# order, and /api/version omits them.
|
||||||
# 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.
|
|
||||||
ARG BUILD_VERSION=dev
|
ARG BUILD_VERSION=dev
|
||||||
ARG BUILD_KEY=
|
ARG BUILD_KEY=
|
||||||
ARG BUILD_CHANNEL=
|
ARG BUILD_CHANNEL=
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ health:
|
|||||||
migrate:
|
migrate:
|
||||||
docker compose exec app alembic upgrade head
|
docker compose exec app alembic upgrade head
|
||||||
|
|
||||||
# --- Local dev (requires: pip install -e ".[dev]" and cd frontend && npm ci) ---
|
# --- Local dev (requires: uv sync --extra dev and cd frontend && npm ci) ---
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
ruff check src/
|
ruff check src/
|
||||||
@@ -39,9 +39,8 @@ check: lint typecheck test
|
|||||||
|
|
||||||
# --- Plugin ---
|
# --- Plugin ---
|
||||||
|
|
||||||
# Run this after changing anything under plugin/ or .claude-plugin/, BEFORE
|
# Run after changing plugin/ or .claude-plugin/, before committing: the plugin
|
||||||
# committing. The plugin ships straight from git with no build step, so its
|
# ships from git with no build step, so its version is minted here. CI fails if
|
||||||
# version is minted here rather than stamped by CI; the lane fails if you
|
# the version wasn't bumped.
|
||||||
# forget, but this is what makes remembering cheap.
|
|
||||||
mint-plugin:
|
mint-plugin:
|
||||||
python3 scripts/mint_plugin_version.py
|
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
|
## 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, tasks and issues; projects with milestones as plans; rules that reach Claude when its work matches them or at a moment of work; lessons, snippets of prior art and a ledger of the code they account for; design systems; ideas shared across projects on the same platform; and stored processes. Semantic search feeds both Claude's recall and the plugin's context injection. A knowledge graph, sharing with users and groups, and scheduled backups round it out. See [Features](docs/features.md).
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export SECRET_KEY=your-random-secret-here
|
|||||||
docker compose -f docker-compose.quickstart.yml up -d
|
docker compose -f docker-compose.quickstart.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:5000`. The first user to register becomes admin. To connect Claude, create an API key under **Settings → API Keys** and install the Claude Code plugin — see [API Keys & MCP](docs/api-keys-and-mcp.md).
|
Open `http://localhost:5000`. The first user to register becomes admin. To connect Claude, create an API key under **Settings → MCP Access** and install the Claude Code plugin — see [API Keys & MCP](docs/api-keys-and-mcp.md).
|
||||||
|
|
||||||
> **Development:** To build from source, see [Development](docs/development.md).
|
> **Development:** To build from source, see [Development](docs/development.md).
|
||||||
|
|
||||||
@@ -31,8 +31,10 @@ Open `http://localhost:5000`. The first user to register becomes admin. To conne
|
|||||||
| [Configuration](docs/configuration.md) | Environment variables, Docker Compose, production setup, security |
|
| [Configuration](docs/configuration.md) | Environment variables, Docker Compose, production setup, security |
|
||||||
| [Features](docs/features.md) | Detailed feature breakdown and keyboard shortcuts |
|
| [Features](docs/features.md) | Detailed feature breakdown and keyboard shortcuts |
|
||||||
| [Development](docs/development.md) | Dev workflow, CI/CD, migrations, release process |
|
| [Development](docs/development.md) | Dev workflow, CI/CD, migrations, release process |
|
||||||
| [API Keys & MCP](docs/api-keys-and-mcp.md) | API key management and Fable MCP install guide |
|
| [API Keys & MCP](docs/api-keys-and-mcp.md) | API keys and installing the Claude Code plugin |
|
||||||
| [SSO / OAuth](docs/sso-oauth.md) | OIDC setup for Authentik, Keycloak, and other providers |
|
| [SSO / OAuth](docs/sso-oauth.md) | OIDC setup for Authentik, Keycloak, and other providers |
|
||||||
|
| [Backups](docs/backups.md) | Scheduled backups to a folder or S3, and restoring from one |
|
||||||
|
| [Design system](docs/design-system.md) | The FabledSword house style and how Scribe applies it |
|
||||||
| [API Reference](docs/api-reference.md) | All REST API endpoints |
|
| [API Reference](docs/api-reference.md) | All REST API endpoints |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
|
|||||||
|
|
||||||
from scribe.config import Config
|
from scribe.config import Config
|
||||||
from scribe.models import Base
|
from scribe.models import Base
|
||||||
|
from scribe.services.db_ready import wait_for_database
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
@@ -32,6 +33,7 @@ def do_run_migrations(connection):
|
|||||||
|
|
||||||
|
|
||||||
async def run_async_migrations() -> None:
|
async def run_async_migrations() -> None:
|
||||||
|
await wait_for_database(Config.DATABASE_URL)
|
||||||
engine = create_async_engine(Config.DATABASE_URL)
|
engine = create_async_engine(Config.DATABASE_URL)
|
||||||
async with engine.connect() as connection:
|
async with engine.connect() as connection:
|
||||||
await connection.run_sync(do_run_migrations)
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
"""replace events.end_dt with duration_minutes (Fable #160)
|
"""replace events.end_dt with duration_minutes
|
||||||
|
|
||||||
Revision ID: 0043
|
Revision ID: 0043
|
||||||
Revises: 0042
|
Revises: 0042
|
||||||
Create Date: 2026-04-29
|
Create Date: 2026-04-29
|
||||||
|
|
||||||
Structural fix for the "end before start" bug class observed on prod
|
Structural fix for the "end before start" bug class: an event whose end_dt
|
||||||
2026-04-29: an event landed with end_dt 32 days before start_dt due
|
precedes its start_dt disappears from upcoming-list filters. Storing duration instead of end_dt makes the invalid state
|
||||||
to a tool-call mishap, then disappeared from upcoming-list filters.
|
|
||||||
Storing duration instead of end_dt makes the invalid state
|
|
||||||
inexpressible at the schema level (duration_minutes >= 0).
|
inexpressible at the schema level (duration_minutes >= 0).
|
||||||
|
|
||||||
Backfill rules:
|
Backfill rules:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Revises: 0043
|
|||||||
Create Date: 2026-05-13
|
Create Date: 2026-05-13
|
||||||
|
|
||||||
Adds two columns to the ``notes`` table to support the task-as-durable-record
|
Adds two columns to the ``notes`` table to support the task-as-durable-record
|
||||||
design (spec 2026-05-13):
|
design:
|
||||||
|
|
||||||
- ``description``: user-stated goal / initial context. Meaningful when
|
- ``description``: user-stated goal / initial context. Meaningful when
|
||||||
``is_task=true``; left NULL on knowledge notes.
|
``is_task=true``; left NULL on knowledge notes.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Revision ID: 0048
|
|||||||
Revises: 0047
|
Revises: 0047
|
||||||
Create Date: 2026-05-22
|
Create Date: 2026-05-22
|
||||||
|
|
||||||
Phase 2 of the conversation+curator architecture (Fable #172). The
|
Part of the conversation+curator architecture. The
|
||||||
scheduler runs every 15 minutes and processes any journal conversation
|
scheduler runs every 15 minutes and processes any journal conversation
|
||||||
with messages newer than its `last_curator_run_at` timestamp. NULL
|
with messages newer than its `last_curator_run_at` timestamp. NULL
|
||||||
means "never run; process all of today on first sweep."
|
means "never run; process all of today on first sweep."
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Revision ID: 0049
|
|||||||
Revises: 0048
|
Revises: 0048
|
||||||
Create Date: 2026-05-22
|
Create Date: 2026-05-22
|
||||||
|
|
||||||
Phase 3 of the conversation+curator architecture (Fable #172). The
|
Part of the conversation+curator architecture. The
|
||||||
curator's final summary line (≤ 240 chars) is persisted here so the
|
curator's final summary line (≤ 240 chars) is persisted here so the
|
||||||
NEXT chat turn can include it in the system prompt. This is the
|
NEXT chat turn can include it in the system prompt. This is the
|
||||||
feedback loop that closes the architecture — without it, the chat
|
feedback loop that closes the architecture — without it, the chat
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ Revision ID: 0053
|
|||||||
Revises: 0052
|
Revises: 0052
|
||||||
Create Date: 2026-05-27
|
Create Date: 2026-05-27
|
||||||
|
|
||||||
Phase 9 of the MCP-first pivot. The Python models for these tables were
|
Part of the MCP-first pivot. The Python models for these tables are gone;
|
||||||
deleted in Phase 8 (commit 91bafb6); this migration drops the orphan
|
this migration drops the orphan
|
||||||
SQL tables and cleans up dead per-user setting rows.
|
SQL tables and cleans up dead per-user setting rows.
|
||||||
|
|
||||||
Hard-cutover: existing rows in these tables are discarded. The
|
Hard-cutover: existing rows in these tables are discarded, deliberately.
|
||||||
accompanying spec at docs/superpowers/specs/2026-05-26-mcp-first-pivot-design.md
|
|
||||||
explicitly accepted this loss.
|
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Revision ID: 0054
|
|||||||
Revises: 0053
|
Revises: 0053
|
||||||
Create Date: 2026-05-27
|
Create Date: 2026-05-27
|
||||||
|
|
||||||
The image cache was wired into the LLM image-search tool (removed in Phase 8).
|
The image cache was wired into the LLM image-search tool, which is gone.
|
||||||
With no producer or consumer left, the table is dropped here.
|
With no producer or consumer left, the table is dropped here.
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ Create Date: 2026-05-27
|
|||||||
|
|
||||||
Adds the Scribe Rulebook hierarchy as a fourth top-level entity, sibling
|
Adds the Scribe Rulebook hierarchy as a fourth top-level entity, sibling
|
||||||
to Project. Rules carry a structural (statement, why, how_to_apply)
|
to Project. Rules carry a structural (statement, why, how_to_apply)
|
||||||
triple. Projects subscribe to Rulebooks (many-to-many). See the design
|
triple. Projects subscribe to Rulebooks (many-to-many; migration 0101
|
||||||
doc at docs/superpowers/specs/2026-05-27-scribe-rulebook-design.md.
|
later drops subscriptions).
|
||||||
"""
|
"""
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Revision ID: 0066
|
|||||||
Revises: 0065
|
Revises: 0065
|
||||||
Create Date: 2026-06-14
|
Create Date: 2026-06-14
|
||||||
|
|
||||||
T3 of plan #819. The milestone becomes the plan container: its `body` holds
|
The milestone becomes the plan container: its `body` holds
|
||||||
the design/intent/purpose (markdown), `description` stays the one-liner, and
|
the design/intent/purpose (markdown), `description` stays the one-liner, and
|
||||||
individual steps live as first-class child tasks (milestone_id) instead of
|
individual steps live as first-class child tasks (milestone_id) instead of
|
||||||
checkboxes crammed into a kind=plan task body. start_planning is reworked to
|
checkboxes crammed into a kind=plan task body. start_planning is reworked to
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ Revision ID: 0074
|
|||||||
Revises: 0073
|
Revises: 0073
|
||||||
Create Date: 2026-07-31
|
Create Date: 2026-07-31
|
||||||
|
|
||||||
The operator's shape for #254: prose doesn't live as one-offs, it lives centrally
|
Prose doesn't live as one-offs; it lives centrally on the system. Two fields rather than one per category:
|
||||||
on the system. Two fields rather than one per category:
|
|
||||||
|
|
||||||
design_systems.guidance the narrative a token table cannot hold — aesthetic,
|
design_systems.guidance the narrative a token table cannot hold — aesthetic,
|
||||||
voice and tone, what is deliberately out of scope.
|
voice and tone, what is deliberately out of scope.
|
||||||
@@ -17,7 +16,7 @@ on the system. Two fields rather than one per category:
|
|||||||
|
|
||||||
Free-form rather than a column per category on purpose. A schema with `voice`,
|
Free-form rather than a column per category on purpose. A schema with `voice`,
|
||||||
`aesthetic` and `scope` columns would bake one rulebook's table of contents into
|
`aesthetic` and `scope` columns would bake one rulebook's table of contents into
|
||||||
every install (rule #115), and the next install's design system would have three
|
every install, and the next install's design system would have three
|
||||||
empty columns and nowhere to put what it actually cares about.
|
empty columns and nowhere to put what it actually cares about.
|
||||||
|
|
||||||
Both nullable: a design system with no prose at all is complete, not a draft.
|
Both nullable: a design system with no prose at all is complete, not a draft.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Revision ID: 0076
|
|||||||
Revises: 0075
|
Revises: 0075
|
||||||
Create Date: 2026-08-07
|
Create Date: 2026-08-07
|
||||||
|
|
||||||
Step 1 of milestone #278. Structure only — nothing reads or writes the new
|
Structure only — nothing reads or writes the new
|
||||||
table yet, and nothing behaves differently after this runs.
|
table yet, and nothing behaves differently after this runs.
|
||||||
|
|
||||||
## What the table is for
|
## What the table is for
|
||||||
@@ -39,7 +39,7 @@ and not for notes, so the column looked like the notes-side half of that
|
|||||||
feature, modelled and abandoned.
|
feature, modelled and abandoned.
|
||||||
|
|
||||||
It is dropped rather than repurposed for supersession, and the distinction is
|
It is dropped rather than repurposed for supersession, and the distinction is
|
||||||
the point (#2483): consolidation folds several records into one survivor and
|
the point: consolidation folds several records into one survivor and
|
||||||
destroys the originals. Merging two snippets is lossless — one helper, several
|
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
|
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
|
actually said. Supersession is the opposite act: both records survive, and the
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Chunked embeddings: one note_embeddings row per chunk (#280)
|
"""Chunked embeddings: one note_embeddings row per chunk
|
||||||
|
|
||||||
Revision ID: 0077
|
Revision ID: 0077
|
||||||
Revises: 0076
|
Revises: 0076
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Forge connections move to the user level (#2778)
|
"""Forge connections move to the user level
|
||||||
|
|
||||||
Revision ID: 0078
|
Revision ID: 0078
|
||||||
Revises: 0077
|
Revises: 0077
|
||||||
@@ -12,7 +12,7 @@ keyring, with an optional per-project pin (projects.forge_connection_id).
|
|||||||
|
|
||||||
The data move carries the existing admin config into a connection row for the
|
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
|
first admin user (host parsed from the base URL), then deletes the old
|
||||||
setting keys outright — no legacy dual-read (rule #22). The env-var channel
|
setting keys outright — no legacy dual-read. The env-var channel
|
||||||
(FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) is untouched by this migration; it
|
(FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) is untouched by this migration; it
|
||||||
survives as an implicit keyring entry for admin users only.
|
survives as an implicit keyring entry for admin users only.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"""The shape ledger: code_shapes (#2787, milestone 294)
|
"""The shape ledger: code_shapes
|
||||||
|
|
||||||
Revision ID: 0079
|
Revision ID: 0079
|
||||||
Revises: 0078
|
Revises: 0078
|
||||||
Create Date: 2026-08-19
|
Create Date: 2026-08-19
|
||||||
|
|
||||||
The accounting half of the pattern system (governing note 2786): the snippet
|
The accounting half of the pattern system: the snippet
|
||||||
library records canon (small); this table accounts for EVERY shape the
|
library records canon (small); this table accounts for EVERY shape the
|
||||||
coverage extractor finds in a bound repo (total). Rows arrive `unclassified`
|
coverage extractor finds in a bound repo (total). Rows arrive `unclassified`
|
||||||
from the coverage sync (step 2) and gain judgments — canonical / instance /
|
from the coverage sync and gain judgments — canonical / instance /
|
||||||
variant / exempt — from audits, hooks, and the mechanical proposer.
|
variant / exempt — from audits, hooks, and the mechanical proposer.
|
||||||
Unclassified IS the todo list.
|
Unclassified IS the todo list.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Shape fingerprints + the mechanical proposer's columns (#2792, milestone 294)
|
"""Shape fingerprints + the mechanical proposer's columns
|
||||||
|
|
||||||
Revision ID: 0080
|
Revision ID: 0080
|
||||||
Revises: 0079
|
Revises: 0079
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Shape history, recheck, and the divergence flag (#2793, milestone 294)
|
"""Shape history, recheck, and the divergence flag
|
||||||
|
|
||||||
Revision ID: 0081
|
Revision ID: 0081
|
||||||
Revises: 0080
|
Revises: 0080
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"""Per-binding ref — the branch a project's ledger follows (#2873, milestone 294)
|
"""Per-binding ref — the branch a project's ledger follows
|
||||||
|
|
||||||
Revision ID: 0082
|
Revision ID: 0082
|
||||||
Revises: 0081
|
Revises: 0081
|
||||||
Create Date: 2026-08-21
|
Create Date: 2026-08-21
|
||||||
|
|
||||||
A repo binding used to imply the repo's default branch; the shape ledger
|
Without a ref, a repo binding implies the repo's default branch, so the shape
|
||||||
therefore only saw work after a merge to main, while the operator's work
|
ledger only sees work after a merge — while a project may do its work on a
|
||||||
lands on dev (rule 1). `ref` names the branch the coverage refresh reads —
|
dev branch. `ref` names the branch the coverage refresh reads; NULL means the
|
||||||
NULL keeps today's behaviour (the forge's default branch).
|
forge's default branch.
|
||||||
"""
|
"""
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Exempt/variant reason codes — a small fixed catalogue beside the prose (#2874, milestone 294)
|
"""Exempt/variant reason codes — a small fixed catalogue beside the prose
|
||||||
|
|
||||||
Revision ID: 0083
|
Revision ID: 0083
|
||||||
Revises: 0082
|
Revises: 0082
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""code_shape_uses — consumption edges, separate from conformance (#2870, milestone 294)
|
"""code_shape_uses — consumption edges, separate from conformance
|
||||||
|
|
||||||
Revision ID: 0084
|
Revision ID: 0084
|
||||||
Revises: 0083
|
Revises: 0083
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Project inception: the decision record + always-on rulebook exclusions (milestone 297)
|
"""Project inception: the decision record + always-on rulebook exclusions
|
||||||
|
|
||||||
Revision ID: 0085
|
Revision ID: 0085
|
||||||
Revises: 0084
|
Revises: 0084
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""code_shape_consumers — the CSS consumer map (milestone 302, note 2917)
|
"""code_shape_consumers — the CSS consumer map
|
||||||
|
|
||||||
Revision ID: 0086
|
Revision ID: 0086
|
||||||
Revises: 0085
|
Revises: 0085
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"""canonical_systems — the global area vocabulary, promoted from a constant
|
"""canonical_systems — the global area vocabulary, promoted from a constant
|
||||||
to a table (milestone 307 step 1, decision note 3026)
|
to a table
|
||||||
|
|
||||||
Revision ID: 0087
|
Revision ID: 0087
|
||||||
Revises: 0086
|
Revises: 0086
|
||||||
Create Date: 2026-08-26
|
Create Date: 2026-08-26
|
||||||
|
|
||||||
The eight standard area names already existed as `STANDARD_SYSTEMS`, a tuple in
|
The eight standard area names already existed as `STANDARD_SYSTEMS`, a tuple in
|
||||||
services/systems.py that milestone 297 seeds into a project at inception. A
|
services/systems.py that is seeded into a project at inception. A
|
||||||
constant cannot be referenced: a rule that applies across projects has nothing
|
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
|
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
|
be chained to one project's row. This makes the vocabulary a table so it can be
|
||||||
@@ -35,7 +35,7 @@ depends_on = None
|
|||||||
# service computes (canonical_slug: lowercase, "&" -> "and", non-alphanumerics
|
# service computes (canonical_slug: lowercase, "&" -> "and", non-alphanumerics
|
||||||
# collapsed to "-"). Charters stay generic on purpose: a project refines its
|
# 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,
|
# own System's description, never this one. Nothing here names an app, a repo,
|
||||||
# a vendor or a house convention — the catalog ships to every install (rule 115).
|
# a vendor or a house convention — the catalog ships to every install.
|
||||||
_SEED = (
|
_SEED = (
|
||||||
("CI & Release", "ci-and-release",
|
("CI & Release", "ci-and-release",
|
||||||
"How the project is verified and shipped: pipelines, runners, image/artifact builds, release tagging and rollback."),
|
"How the project is verified and shipped: pipelines, runners, image/artifact builds, release tagging and rollback."),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""rules gain a trigger, a tier, canon tags and typed edges (milestone 307
|
"""rules gain a trigger, a tier, canon tags and typed edges
|
||||||
step 3, decision note 3026)
|
|
||||||
|
|
||||||
Revision ID: 0088
|
Revision ID: 0088
|
||||||
Revises: 0087
|
Revises: 0087
|
||||||
@@ -31,8 +30,8 @@ down_revision = "0087"
|
|||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
# Kept in one place so upgrade and the CHECK agree by construction (rule 36:
|
# Kept in one place so upgrade and the CHECK agree by construction (a
|
||||||
# a whitelisted value means DROP + ADD CONSTRAINT in the same migration —
|
# whitelisted value means DROP + ADD CONSTRAINT in the same migration —
|
||||||
# there is no prior constraint here, so the pair is created together).
|
# there is no prior constraint here, so the pair is created together).
|
||||||
_TIERS = ("always_on", "conditional")
|
_TIERS = ("always_on", "conditional")
|
||||||
_RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
|
_RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""rule_embeddings — rules become findable by meaning (milestone 307 step 4,
|
"""rule_embeddings — rules become findable by meaning
|
||||||
decision note 3026)
|
|
||||||
|
|
||||||
Revision ID: 0089
|
Revision ID: 0089
|
||||||
Revises: 0088
|
Revises: 0088
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""a rule can carry its own check — verify_with, expires_when, verified_at
|
"""a rule can carry its own check — verify_with, expires_when, verified_at
|
||||||
(milestone 312 step 1)
|
|
||||||
|
|
||||||
Revision ID: 0090
|
Revision ID: 0090
|
||||||
Revises: 0089
|
Revises: 0089
|
||||||
@@ -11,10 +10,9 @@ know they did. A CONSTRAINT is a fact about someone else's software: a
|
|||||||
runner's shell, a bot's config, a tool that exists. Nobody is present when
|
runner's shell, a bot's config, a tool that exists. Nobody is present when
|
||||||
that goes false.
|
that goes false.
|
||||||
|
|
||||||
Milestone 307's rulebook audit found nine stale sites. Every one was a
|
What goes stale in a rulebook is the constraints, not the norms — and a
|
||||||
constraint; not one norm had rotted. One of them had been telling every
|
stale constraint keeps instructing every session that loads it, long after
|
||||||
session to skip database-backed tests for weeks while the integration lane
|
the world it described has moved.
|
||||||
sat green in the workflow.
|
|
||||||
|
|
||||||
Three nullable columns, so a rule can say how to check itself:
|
Three nullable columns, so a rule can say how to check itself:
|
||||||
|
|
||||||
@@ -31,7 +29,7 @@ none of them. A null `verify_with` is not an omission — it is the honest
|
|||||||
marker of "this one is a decision, and there is nothing to go and check."
|
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.
|
That signal only works if the field stays empty wherever it belongs empty.
|
||||||
|
|
||||||
No CHECK constraint is involved, so rule 36 does not apply here. Nothing is
|
No CHECK constraint is involved. Nothing is
|
||||||
backfilled: a migration cannot invent a check any more than 0088 could
|
backfilled: a migration cannot invent a check any more than 0088 could
|
||||||
invent a trigger.
|
invent a trigger.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""task_kind gains 'spike' — the investigation, not the change
|
"""task_kind gains 'spike' — the investigation, not the change
|
||||||
(milestone 312 step 5)
|
|
||||||
|
|
||||||
Revision ID: 0091
|
Revision ID: 0091
|
||||||
Revises: 0090
|
Revises: 0090
|
||||||
@@ -12,12 +11,12 @@ end of it. "Find out whether the runner can be given a bash shell" is not
|
|||||||
work, and filing it as work makes a finished investigation look like an
|
work, and filing it as work makes a finished investigation look like an
|
||||||
abandoned change.
|
abandoned change.
|
||||||
|
|
||||||
It is the record a failed check asks for. Milestone 312 gave rules a
|
It is the record a failed check asks for. Rules carry a
|
||||||
`verify_with`; when one of those fails, the rule is wrong and the next move
|
`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
|
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.
|
exists (0065), so that constraint -> spike link needs no further schema.
|
||||||
|
|
||||||
Rule 36: `task_kind` is gated by a CHECK whitelist, so the value and the
|
`task_kind` is gated by a CHECK whitelist, so the value and the
|
||||||
widened constraint land in the SAME migration — DROP then ADD, exactly as
|
widened constraint land in the SAME migration — DROP then ADD, exactly as
|
||||||
0065 did when it introduced 'issue'. Adding the value and constraining it
|
0065 did when it introduced 'issue'. Adding the value and constraining it
|
||||||
later leaves a window where the database accepts anything.
|
later leaves a window where the database accepts anything.
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""a note can carry its own check — verify_with, expires_when, verified_at
|
"""a note can carry its own check — verify_with, expires_when, verified_at
|
||||||
(milestone 317 step 1)
|
|
||||||
|
|
||||||
Revision ID: 0092
|
Revision ID: 0092
|
||||||
Revises: 0091
|
Revises: 0091
|
||||||
@@ -32,8 +31,7 @@ Three nullable columns:
|
|||||||
|
|
||||||
WHICH ROWS THESE ARE FOR. `notes` is one table holding notes, tasks,
|
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,
|
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
|
non-snippet records are OFFERED them (gated at the service): a task's decay is its status, and a snippet
|
||||||
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
|
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
|
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
|
the schema would have meant a partial index or a CHECK across three columns
|
||||||
@@ -44,7 +42,7 @@ value of the sweep is that its output is short. A null `verify_with` is not
|
|||||||
an omission; it is the honest marker of "this one is a decision, and there
|
an omission; it is the honest marker of "this one is a decision, and there
|
||||||
is nothing to go and check."
|
is nothing to go and check."
|
||||||
|
|
||||||
No CHECK constraint is involved, so rule 36 does not apply. Nothing is
|
No CHECK constraint is involved. Nothing is
|
||||||
backfilled: a migration cannot invent a check.
|
backfilled: a migration cannot invent a check.
|
||||||
"""
|
"""
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
@@ -69,9 +67,8 @@ def upgrade() -> None:
|
|||||||
#
|
#
|
||||||
# Still a sequential scan's job at this size, and an index on
|
# 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
|
# (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
|
# on every note write to serve one operator-initiated query. If a real
|
||||||
# live acceptance measures otherwise, add it there against a real plan
|
# plan says otherwise, add it then rather than guessing here.
|
||||||
# rather than guessing here.
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""rules gain an edit history — rule_versions (milestone 323 step 1)
|
"""rules gain an edit history — rule_versions
|
||||||
|
|
||||||
Revision ID: 0093
|
Revision ID: 0093
|
||||||
Revises: 0092
|
Revises: 0092
|
||||||
@@ -7,12 +7,9 @@ Create Date: 2026-08-29
|
|||||||
The sibling `note_versions` has had for a long time. A note's every meaningful
|
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
|
edit is snapshotted, and the design-system note calls that history "the
|
||||||
changelog". A RULE — which binds behaviour on every session that loads it —
|
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.
|
had nothing: an edit destroyed what it used to say, with no record anywhere,
|
||||||
|
and keeping a superseded statement meant a person remembering to copy it
|
||||||
Rescoping rule 79 on 2026-08-29 is what surfaced it. The superseded statement
|
somewhere. The more consequential record had the weaker protection.
|
||||||
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
|
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:
|
guard that exists there for a reason that does not hold here:
|
||||||
@@ -31,7 +28,7 @@ guard that exists there for a reason that does not hold here:
|
|||||||
CASCADE: deleting a user must not erase the history of the rules they edited.
|
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.
|
The edit still happened and the rule still binds because of it.
|
||||||
|
|
||||||
No CHECK constraint, so rule 36 does not apply. Nothing is backfilled — a
|
No CHECK constraint is involved. Nothing is backfilled — a
|
||||||
migration cannot invent the text a rule used to have, and inventing "the
|
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
|
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.
|
would look like a record of an edit that never occurred.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""add rule_usage_events — was a surfaced rule ever read? (milestone 333 step 1)
|
"""add rule_usage_events — was a surfaced rule ever read?
|
||||||
|
|
||||||
Revision ID: 0094
|
Revision ID: 0094
|
||||||
Revises: 0093
|
Revises: 0093
|
||||||
@@ -9,18 +9,15 @@ table to arrive after `rule_embeddings` and `rule_versions` — each one added
|
|||||||
because the rule side kept inheriting machinery built for notes and getting
|
because the rule side kept inheriting machinery built for notes and getting
|
||||||
the weaker version of it.
|
the weaker version of it.
|
||||||
|
|
||||||
WHAT IT MEASURES. The write-path standing-rule arm is the only retrieval
|
WHAT IT MEASURES. A standing-rule arm that returns something on every call
|
||||||
surface in Scribe whose usefulness cannot be observed, and — not coincidentally
|
and clears its threshold every time is either a perfectly tuned surface or a
|
||||||
— the only one that has never declined to fire. Over 30 days it took 296 calls,
|
bar it cannot fail to clear, and `retrieval_logs`
|
||||||
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
|
cannot tell them apart: it records what the ranker scored, never whether the
|
||||||
hint was any use.
|
hint was any use.
|
||||||
|
|
||||||
WHY NOT A rule_id COLUMN ON note_usage_events. The row shares no note-specific
|
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
|
fields and the aggregate readout is the same shape, which is the strongest case
|
||||||
for sharing that note #3163 admits. What decides against it is identity at
|
for sharing there is. What decides against it is identity at
|
||||||
RESTORE: `note_usage_events`'s importer maps `note_id` through `note_id_map`
|
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
|
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 —
|
back from a backup silently reattached to whatever note took that number —
|
||||||
@@ -34,8 +31,8 @@ and dies with it; telemetry outlives the row it describes. Deleting a rule must
|
|||||||
not erase the evidence that it was surfaced forty times and opened never, since
|
not erase the evidence that it was surfaced forty times and opened never, since
|
||||||
that evidence is exactly the case for having deleted it.
|
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
|
No CHECK on `event`, matching the note twin. DROP + ADD governs adding a value
|
||||||
a column that is already gated; it does not require gating one that never was,
|
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
|
and a two-member enum whose members are written by two functions in one module
|
||||||
is not where that discipline earns its cost.
|
is not where that discipline earns its cost.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""add retrieval_logs.suppressed_count — tell a ranker decline from a repeat (#3497)
|
"""add retrieval_logs.suppressed_count — tell a ranker decline from a repeat
|
||||||
|
|
||||||
Revision ID: 0095
|
Revision ID: 0095
|
||||||
Revises: 0094
|
Revises: 0094
|
||||||
@@ -20,9 +20,8 @@ the dropped rows never come back and there is nothing to count.
|
|||||||
NULLABLE, AND THE NULL IS THE POINT. A surface that does not measure
|
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"
|
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
|
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
|
a perfectly clean one — the substitution of an artifact for a measurement
|
||||||
measurement that #3311 made and that #3497 exists to correct. Doing it again,
|
that this column exists to prevent.
|
||||||
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
|
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,
|
saying so is the honest state. `retrieval_logs` is not restored from backup,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""add retrieval_logs.best_available_score — the score the bar rejected (#3670)
|
"""add retrieval_logs.best_available_score — the score the bar rejected
|
||||||
|
|
||||||
Revision ID: 0096
|
Revision ID: 0096
|
||||||
Revises: 0095
|
Revises: 0095
|
||||||
@@ -34,7 +34,7 @@ NULLABLE, AND UNBACKFILLED, for the reason 0095 spells out: a row written
|
|||||||
before this shipped genuinely does not know what its best rejected candidate
|
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
|
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
|
corpus had nothing remotely relevant" — an artifact standing in for a
|
||||||
measurement, which is the whole defect this milestone corrects.
|
measurement, which is the defect this column corrects.
|
||||||
|
|
||||||
`retrieval_logs` is not restored from backup, so no importer changes.
|
`retrieval_logs` is not restored from backup, so no importer changes.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""add retrieval_logs.best_available_id — WHICH record the bar refused (#3807)
|
"""add retrieval_logs.best_available_id — WHICH record the bar refused
|
||||||
|
|
||||||
Revision ID: 0097
|
Revision ID: 0097
|
||||||
Revises: 0096
|
Revises: 0096
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""rules gain a `kind` — a preference is a rule that does not bind (#3849)
|
"""rules gain a `kind` — a preference is a rule that does not bind
|
||||||
|
|
||||||
Revision ID: 0098
|
Revision ID: 0098
|
||||||
Revises: 0097
|
Revises: 0097
|
||||||
@@ -37,7 +37,7 @@ install upgrades and every existing row keeps the force it had. That is the
|
|||||||
same reasoning 0088 used for `tier`, and it is the reason both are safe to
|
same reasoning 0088 used for `tier`, and it is the reason both are safe to
|
||||||
apply without reading the data first.
|
apply without reading the data first.
|
||||||
|
|
||||||
The CHECK is created with the column (rule 36: there is no prior constraint,
|
The CHECK is created with the column (there is no prior constraint,
|
||||||
so the pair is created together — a value added to it LATER does DROP + ADD
|
so the pair is created together — a value added to it LATER does DROP + ADD
|
||||||
in one migration).
|
in one migration).
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Create Date: 2026-09-11
|
|||||||
`pre_tool_rule` retrieves against the RAW COMMAND TEXT and `write_path_rule`
|
`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
|
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
|
buffer is what `record_retrieval` wrote into `retrieval_logs.query`. A command
|
||||||
that exported a token stored the token (#3925).
|
that exported a token stored the token.
|
||||||
|
|
||||||
`services/retrieval_telemetry.scrub_secrets` closes that going forward — the
|
`services/retrieval_telemetry.scrub_secrets` closes that going forward — the
|
||||||
value never reaches the column. It cannot reach BACKWARDS, and this does: it
|
value never reaches the column. It cannot reach BACKWARDS, and this does: it
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ Revision ID: 0100
|
|||||||
Revises: 0099
|
Revises: 0099
|
||||||
Create Date: 2026-09-11
|
Create Date: 2026-09-11
|
||||||
|
|
||||||
Milestone 394. Every rule now reaches a session by retrieval — because
|
Every rule reaches a session by retrieval — because
|
||||||
something it is about to do made the rule relevant — and the machinery that
|
something it is about to do made the rule relevant — and the machinery that
|
||||||
delivered rules unconditionally goes with it.
|
delivered rules unconditionally goes with it.
|
||||||
|
|
||||||
WHAT GOES, AND WHERE IT CAME FROM
|
WHAT GOES, AND WHERE IT CAME FROM
|
||||||
|
|
||||||
- ``rules.tier`` and its ``ck_rules_tier`` CHECK (migration 0088). Dropping
|
- ``rules.tier`` and its ``ck_rules_tier`` CHECK (migration 0088). Dropping
|
||||||
the column takes the constraint with it. Rule 36 is about ADDING a value to
|
the column takes the constraint with it. DROP + ADD is about ADDING a value to
|
||||||
a live whitelist, which needs DROP + ADD in the same migration; it does not
|
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
|
speak to removing the column outright, and saying so here is cheaper than
|
||||||
the next reader wondering whether it was forgotten.
|
the next reader wondering whether it was forgotten.
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ Revision ID: 0101
|
|||||||
Revises: 0100
|
Revises: 0100
|
||||||
Create Date: 2026-09-15
|
Create Date: 2026-09-15
|
||||||
|
|
||||||
Milestone 414. A rule lives in a rulebook topic, where it is GLOBAL, or on one
|
A rule lives in a rulebook topic, where it is GLOBAL, or on one project, and
|
||||||
project, and retrieval reads that home directly (step 1). Subscriptions were
|
retrieval reads that home directly. Subscriptions were the last thing that
|
||||||
the last thing that pretended a rulebook reached some projects and not others,
|
pretended a rulebook reached some projects and not others, and with no
|
||||||
and after milestone 394 they changed nothing a session received — only what a
|
always-on tier they changed nothing a session received — only what a
|
||||||
project's rule LISTING showed. Operator, 2026-09-15: "we have global and
|
project's rule LISTING showed. Global and project-scoped rules cover what
|
||||||
project scoped rules, we don't need the subscriptions now."
|
subscriptions were for.
|
||||||
|
|
||||||
WHAT GOES
|
WHAT GOES
|
||||||
|
|
||||||
@@ -19,8 +19,8 @@ WHAT GOES
|
|||||||
there is nothing to mute; a project that departs from a global rule writes
|
there is nothing to mute; a project that departs from a global rule writes
|
||||||
a project rule with an ``overrides`` relation, which says why.
|
a project rule with an ``overrides`` relation, which says why.
|
||||||
- The ``subscribe_rulebooks`` key inside ``projects.inception.choices``, and
|
- The ``subscribe_rulebooks`` key inside ``projects.inception.choices``, and
|
||||||
the ``exclude_always_on_rulebooks`` key milestone 394 left behind in the
|
the ``exclude_always_on_rulebooks`` key the retired always-on tier left
|
||||||
same place. Both describe decisions that can no longer be made; a stored
|
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.
|
record carrying them would be read back as a choice the product offers.
|
||||||
|
|
||||||
IRREVERSIBLE, AND THE DOWNGRADE SAYS SO
|
IRREVERSIBLE, AND THE DOWNGRADE SAYS SO
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""milestone_embeddings — a plan becomes findable by meaning (milestone 415)
|
"""milestone_embeddings — a plan becomes findable by meaning
|
||||||
|
|
||||||
Revision ID: 0102
|
Revision ID: 0102
|
||||||
Revises: 0101
|
Revises: 0101
|
||||||
@@ -9,8 +9,8 @@ lives in — could not be found at all. So "is there already a plan for this?"
|
|||||||
had no tool, and a project whose roadmap was written as milestones had every
|
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.
|
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
|
The sibling of rule_embeddings (0089), for the reasons its model docstring
|
||||||
note 3163 give. The vectors are DERIVED: nothing is backfilled here, the startup
|
gives. The vectors are DERIVED: nothing is backfilled here, the startup
|
||||||
backfill writes them.
|
backfill writes them.
|
||||||
"""
|
"""
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
"""retrieval_tuning_events — why a floor is where it is (#4102)
|
"""retrieval_tuning_events — why a floor is where it is
|
||||||
|
|
||||||
Revision ID: 0103
|
Revision ID: 0103
|
||||||
Revises: 0102
|
Revises: 0102
|
||||||
Create Date: 2026-09-17
|
Create Date: 2026-09-17
|
||||||
|
|
||||||
Milestone 416 stops shipping similarity thresholds as values somebody has to
|
Similarity thresholds are not values somebody has to defend: the model that
|
||||||
defend, and hands the adjustment to the model that reads the surface's own
|
reads the surface's own telemetry adjusts them, and the operator can still
|
||||||
telemetry. The operator's decision:
|
touch them.
|
||||||
|
|
||||||
"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
|
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
|
home is the ARGUMENT, and once the values move on their own the argument is the
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""retrieval_tuning_events carries the space each number was measured in (#4104)
|
"""retrieval_tuning_events carries the space each number was measured in
|
||||||
|
|
||||||
Revision ID: 0104
|
Revision ID: 0104
|
||||||
Revises: 0103
|
Revises: 0103
|
||||||
Create Date: 2026-09-17
|
Create Date: 2026-09-17
|
||||||
|
|
||||||
Milestone 416 step 6. A retrieval floor is a cosine distance in ONE embedding
|
A retrieval floor is a cosine distance in ONE embedding
|
||||||
model's geometry, computed over documents cut one particular way. Change the
|
model'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
|
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
|
embeds different text. Either way a number chosen before the change is a
|
||||||
@@ -16,7 +16,7 @@ merely unknown.
|
|||||||
the startup backfill re-embeds precisely what is stale instead of wiping the
|
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.
|
table. These two columns are that idea applied to the tuned numbers.
|
||||||
|
|
||||||
TWO COLUMNS, NOT ONE (rule 149). A reader has to be able to say WHICH half
|
TWO COLUMNS, NOT ONE. A reader has to be able to say WHICH half
|
||||||
moved: a new embedding model and a re-cut document shape invalidate the same
|
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
|
numbers for different reasons, and a fused `"<model>@<n>"` could only report
|
||||||
that something changed.
|
that something changed.
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
"""note_usage_events records WHICH project the reader was in (#4196, #3735)
|
"""note_usage_events records WHICH project the reader was in
|
||||||
|
|
||||||
Revision ID: 0105
|
Revision ID: 0105
|
||||||
Revises: 0104
|
Revises: 0104
|
||||||
Create Date: 2026-09-20
|
Create Date: 2026-09-20
|
||||||
|
|
||||||
Milestone 385. `RetrievalLog` has carried `project_id` from the start, so
|
`RetrievalLog` has carried `project_id` from the start, so
|
||||||
"this record was SURFACED on project B" has always been answerable. The usage
|
"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
|
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
|
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
|
server-side (see the NoteUsageEvent docstring, which rules that out on
|
||||||
purpose).
|
purpose).
|
||||||
|
|
||||||
That gap sits exactly on the question milestone 385 exists to answer. A
|
That gap sits exactly on the question lessons exist to answer. A lesson's
|
||||||
lesson's whole claim is that it reaches a session on a project it was not
|
whole claim is that it reaches a session on a project it was not written on:
|
||||||
written on; step 8's acceptance is "retrieved on a different project, at the
|
"retrieved on a different project, at the moment it applies, AND opened". Surfacing cross-project: answerable. Opening:
|
||||||
moment it applies, AND opened". Surfacing cross-project: answerable. Opening:
|
|
||||||
answerable. Both at once: not, until this column.
|
answerable. Both at once: not, until this column.
|
||||||
|
|
||||||
WHICH PROJECT THIS IS, because the name is ambiguous and the wrong reading
|
WHICH PROJECT THIS IS, because the name is ambiguous and the wrong reading
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""rule_usage_events carry an outcome, not just a read (#4212, milestone 419)
|
"""rule_usage_events carry an outcome, not just a read
|
||||||
|
|
||||||
Revision ID: 0106
|
Revision ID: 0106
|
||||||
Revises: 0105
|
Revises: 0105
|
||||||
Create Date: 2026-09-20
|
Create Date: 2026-09-20
|
||||||
|
|
||||||
Milestone 419's first step. `rule_usage_events` can say a rule was SURFACED
|
`rule_usage_events` can say a rule was SURFACED
|
||||||
and that it was PULLED. It cannot say what happened next, so these two
|
and that it was PULLED. It cannot say what happened next, so these two
|
||||||
sessions leave identical telemetry:
|
sessions leave identical telemetry:
|
||||||
|
|
||||||
@@ -12,16 +12,14 @@ sessions leave identical telemetry:
|
|||||||
- a rule surfaced, opened, and silently ignored.
|
- a rule surfaced, opened, and silently ignored.
|
||||||
|
|
||||||
The second is the more urgent by a distance, and it is the one the readout
|
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
|
cannot name, and it is what an outcome column is for.
|
||||||
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
|
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
|
plain `sa.Text()` with no constraint — checked in the migration itself, not
|
||||||
assumed from the model — so `applied` and `departed` join `surfaced` and
|
assumed from the model — so `applied` and `departed` join `surfaced` and
|
||||||
`pulled` without a DROP/ADD pair. Rule 36 governs CHECK-whitelisted columns
|
`pulled` without a DROP/ADD pair. That pair is for CHECK-whitelisted columns
|
||||||
and this is not one; noted explicitly because the next reader will reach for
|
and this is not one; noted explicitly because the next reader will reach for
|
||||||
rule 36 here, and should be able to see in one place why it does not bite.
|
it here, and should be able to see in one place why it does not apply.
|
||||||
|
|
||||||
THE THIRD STATE IS DERIVED, AND THAT IS NOT A SHORTCUT. Read-and-silently-
|
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
|
unchanged is the absence of an outcome, and it has to be: an agent that knew
|
||||||
@@ -29,8 +27,8 @@ it was ignoring a rule would not be ignoring it. There is no honest way to ask
|
|||||||
for that event, so nothing here tries. `applied` and `departed` are reported;
|
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
|
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
|
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
|
read as though it had measured something — a statistic that cannot vary
|
||||||
statistic that cannot vary being mistaken for a finding.
|
being mistaken for a finding.
|
||||||
|
|
||||||
`detail` CARRIES THE WHY OF A DEPARTURE, and is the reason this is a column
|
`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
|
rather than two more bare event strings. A departure without its reason is
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""system_embeddings — a charter becomes an ANSWER, not just a filter (#4251)
|
"""system_embeddings — a charter becomes an ANSWER, not just a filter
|
||||||
|
|
||||||
Revision ID: 0107
|
Revision ID: 0107
|
||||||
Revises: 0106
|
Revises: 0106
|
||||||
@@ -13,8 +13,7 @@ to one, and an agent asking where a record belonged had to read every charter
|
|||||||
or guess.
|
or guess.
|
||||||
|
|
||||||
The fourth sibling of note_embeddings (0067), rule_embeddings (0089) and
|
The fourth sibling of note_embeddings (0067), rule_embeddings (0089) and
|
||||||
milestone_embeddings (0102), and for the same reason note 3163 gives about the
|
milestone_embeddings (0102), and for the same reason as the third: the row could be shared, the search cannot. "Where does this belong?"
|
||||||
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
|
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.
|
search can answer it, because a charter is not a note.
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
"""trigger_leaves_the_stored_title — a snippet's and lesson's title is its name (milestone 427)
|
"""trigger_leaves_the_stored_title — a snippet's and lesson's title is its name
|
||||||
|
|
||||||
Revision ID: 0108
|
Revision ID: 0108
|
||||||
Revises: 0107
|
Revises: 0107
|
||||||
Create Date: 2026-09-23
|
Create Date: 2026-09-23
|
||||||
|
|
||||||
Snippets and lessons stored their title as `subject — trigger`, because the
|
Snippets and lessons stored their title as `subject — trigger`, because the
|
||||||
join is what makes these kinds rank on the situation they apply to (#2485) and
|
join is what makes these kinds rank on the situation they apply to and
|
||||||
the stored title WAS the embedded one. Every surface that shows a title then
|
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
|
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.
|
characters, and an injected repeat spent all of it again.
|
||||||
|
|
||||||
The trigger's home is `data` (decision #4157), and since this milestone the
|
The trigger's home is `data`, and the join happens at embed time (`embeddings.document_title`). This revision
|
||||||
join happens at embed time (`embeddings.document_title`). This revision
|
|
||||||
rewrites the rows already stored.
|
rewrites the rows already stored.
|
||||||
|
|
||||||
EXACT-SUFFIX, READ FROM THE ROW'S OWN MIRROR. A title is only rewritten when
|
EXACT-SUFFIX, READ FROM THE ROW'S OWN MIRROR. A title is only rewritten when
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""embeddings_record_their_model — a vector says whose space it lives in (#4132)
|
"""embeddings_record_their_model — a vector says whose space it lives in
|
||||||
|
|
||||||
Revision ID: 0109
|
Revision ID: 0109
|
||||||
Revises: 0108
|
Revises: 0108
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""task_claims — a task can say a session is working it (milestone 381 step 2)
|
"""task_claims — a task can say a session is working it
|
||||||
|
|
||||||
Revision ID: 0110
|
Revision ID: 0110
|
||||||
Revises: 0109
|
Revises: 0109
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""lesson_rule_links — a lesson points at the rule it is an instance of
|
"""lesson_rule_links — a lesson points at the rule it is an instance of
|
||||||
(milestone 440 step 1, #4196)
|
|
||||||
|
|
||||||
Revision ID: 0111
|
Revision ID: 0111
|
||||||
Revises: 0110
|
Revises: 0110
|
||||||
@@ -21,8 +20,8 @@ down_revision = "0110"
|
|||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
# One place, so the CHECK and the model's LINK_STATES cannot drift (rule 36:
|
# One place, so the CHECK and the model's LINK_STATES cannot drift (a new
|
||||||
# a new value later means DROP + ADD CONSTRAINT in the same migration).
|
# value later means DROP + ADD CONSTRAINT in the same migration).
|
||||||
_STATES = ("suggested", "confirmed", "rejected")
|
_STATES = ("suggested", "confirmed", "rejected")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""lesson_no_rule — "no rule fits" is a recorded answer (milestone 440 step 2,
|
"""lesson_no_rule — "no rule fits" is a recorded answer
|
||||||
#4631)
|
|
||||||
|
|
||||||
Revision ID: 0112
|
Revision ID: 0112
|
||||||
Revises: 0111
|
Revises: 0111
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""system_path_patterns — a System names the files that are its area
|
"""system_path_patterns — a System names the files that are its area
|
||||||
(milestone 444 step 3, #4756)
|
|
||||||
|
|
||||||
Revision ID: 0113
|
Revision ID: 0113
|
||||||
Revises: 0112
|
Revises: 0112
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""system_usage_events — were an area's rulings read once its files were
|
"""system_usage_events — were an area's rulings read once its files were
|
||||||
touched? (milestone 444 step 4, #4757)
|
touched?
|
||||||
|
|
||||||
Revision ID: 0114
|
Revision ID: 0114
|
||||||
Revises: 0113
|
Revises: 0113
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""retrieval_judgments — a reviewer's verdict on each line of a logged menu
|
"""retrieval_judgments — a reviewer's verdict on each line of a logged menu
|
||||||
(#4772)
|
|
||||||
|
|
||||||
Revision ID: 0115
|
Revision ID: 0115
|
||||||
Revises: 0114
|
Revises: 0114
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""moment_mappings — an install's own corrections to which actions reach which
|
"""moment_mappings — an install's own corrections to which actions reach which
|
||||||
moment (milestone 458 step 2)
|
moment
|
||||||
|
|
||||||
Revision ID: 0116
|
Revision ID: 0116
|
||||||
Revises: 0115
|
Revises: 0115
|
||||||
@@ -8,7 +8,7 @@ Create Date: 2026-10-05
|
|||||||
The moments and their default actions are code; this table holds what an
|
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.
|
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`,
|
User-owned configuration, so it cascades with the user. No CHECK on `effect`,
|
||||||
which the service validates (rule 36).
|
which the service validates.
|
||||||
"""
|
"""
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""rule_moments — the moments of work a rule arrives at (milestone 458 step 3)
|
"""rule_moments — the moments of work a rule arrives at
|
||||||
|
|
||||||
Revision ID: 0117
|
Revision ID: 0117
|
||||||
Revises: 0116
|
Revises: 0116
|
||||||
@@ -28,7 +28,7 @@ def upgrade() -> None:
|
|||||||
sa.Column("moment", sa.Text(), primary_key=True),
|
sa.Column("moment", sa.Text(), primary_key=True),
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
)
|
)
|
||||||
# The delivery read (step 4) asks "which rules are on this moment?", the
|
# The delivery read asks "which rules are on this moment?", the
|
||||||
# opposite direction from the primary key.
|
# opposite direction from the primary key.
|
||||||
op.create_index("ix_rule_moments_moment", "rule_moments", ["moment"])
|
op.create_index("ix_rule_moments_moment", "rule_moments", ["moment"])
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
"""rule_moment_judgments — whether a rule belongs on a moment, and who said so
|
"""rule_moment_judgments — whether a rule belongs on a moment, and who said so
|
||||||
(milestone 458 step 7, #4925)
|
|
||||||
|
|
||||||
Revision ID: 0118
|
Revision ID: 0118
|
||||||
Revises: 0117
|
Revises: 0117
|
||||||
@@ -23,8 +22,8 @@ branch_labels = None
|
|||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
# One place each, so the CHECKs and the model's JUDGMENT_STATES / SOURCES
|
# 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
|
# cannot drift (a new value later means DROP + ADD CONSTRAINT in the same
|
||||||
# same migration).
|
# migration).
|
||||||
_STATES = ("suggested", "confirmed", "rejected")
|
_STATES = ("suggested", "confirmed", "rejected")
|
||||||
_SOURCES = ("pass", "signal", "edit")
|
_SOURCES = ("pass", "signal", "edit")
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""rule_moment_judgments.misfire — a mount reported as arriving where it does
|
"""rule_moment_judgments.misfire — a mount reported as arriving where it does
|
||||||
not apply (milestone 458 step 7b, #4955)
|
not apply
|
||||||
|
|
||||||
Revision ID: 0119
|
Revision ID: 0119
|
||||||
Revises: 0118
|
Revises: 0118
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""family canon — platforms, family ideas, the adoption ledger and its decision
|
"""family canon — platforms, family ideas, the adoption ledger and its decision
|
||||||
log (milestone 463 step 1)
|
log
|
||||||
|
|
||||||
Revision ID: 0120
|
Revision ID: 0120
|
||||||
Revises: 0119
|
Revises: 0119
|
||||||
@@ -13,7 +13,7 @@ reason), or owed. These tables hold that.
|
|||||||
- `platforms` — a GLOBAL catalog, the canonical_systems shape: no owner, slug
|
- `platforms` — a GLOBAL catalog, the canonical_systems shape: no owner, slug
|
||||||
is the match key. Seeded with generic technology names only. Nothing here
|
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
|
names an app, a repo or a house convention: the catalog ships to every
|
||||||
install (rule 115).
|
install.
|
||||||
- `project_platforms` — which platforms a project is (declared / detected /
|
- `project_platforms` — which platforms a project is (declared / detected /
|
||||||
rejected). Membership is what makes "is this in family?" a lookup.
|
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
|
- `family_ideas` — a NOTE's family state. No new record type: any notes row
|
||||||
@@ -39,7 +39,7 @@ branch_labels = None
|
|||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
# One place per whitelist, so each CHECK and the model's tuple cannot drift
|
# 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).
|
# (a new value later means DROP + ADD CONSTRAINT in one migration).
|
||||||
_MEMBERSHIP = ("declared", "detected", "rejected")
|
_MEMBERSHIP = ("declared", "detected", "rejected")
|
||||||
_IDEA_STATUSES = ("candidate", "canon", "retired")
|
_IDEA_STATUSES = ("candidate", "canon", "retired")
|
||||||
_ADOPTION_STATUSES = ("unassessed", "adopted", "variant", "exempt", "owed")
|
_ADOPTION_STATUSES = ("unassessed", "adopted", "variant", "exempt", "owed")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""retire the report_preference arm's rows (milestone 500 step 4)
|
"""retire the report_preference arm's rows
|
||||||
|
|
||||||
Revision ID: 0121
|
Revision ID: 0121
|
||||||
Revises: 0120
|
Revises: 0120
|
||||||
@@ -6,8 +6,8 @@ Create Date: 2026-10-09
|
|||||||
|
|
||||||
The fixed-question arm that searched for completion-report preferences when a
|
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
|
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
|
now arrive on the moments. Its rows go with it (there is no legacy to
|
||||||
22 — no legacy to preserve), because each one would otherwise mean something
|
preserve), because each one would otherwise mean something
|
||||||
different once the arm is no longer registered:
|
different once the arm is no longer registered:
|
||||||
|
|
||||||
- `retrieval_logs` / `retrieval_judgments` — a source missing from the
|
- `retrieval_logs` / `retrieval_judgments` — a source missing from the
|
||||||
@@ -21,11 +21,10 @@ different once the arm is no longer registered:
|
|||||||
- `settings` — the arm's floor and budget keys, which nothing reads.
|
- `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
|
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`
|
against the live tables, which CI never has. `retrieval_logs`
|
||||||
and `retrieval_judgments` are indexed on `source`. `rule_usage_events` is not,
|
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
|
so its delete is also bounded by `created_at` (indexed): no row of the arm
|
||||||
milestone 409 step 4, decided 2026-09-14, so no row of it predates
|
predates `ARM_BORN`, and the scan covers weeks rather than the table's whole history.
|
||||||
`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.
|
Not reversible: the arm is not coming back, so neither are its rows.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Seal forge-connection tokens at rest (milestone 523)
|
"""Seal forge-connection tokens at rest
|
||||||
|
|
||||||
Revision ID: 0122
|
Revision ID: 0122
|
||||||
Revises: 0121
|
Revises: 0121
|
||||||
|
|||||||
+12
-22
@@ -16,11 +16,10 @@ real Postgres), build (docker buildx).
|
|||||||
## Image deps used
|
## Image deps used
|
||||||
|
|
||||||
- python 3.14
|
- python 3.14
|
||||||
- node 24 (used for `npm ci` + `vue-tsc` in the typecheck job, and as the
|
- node 24 (`npm ci` + `vue-tsc` in the typecheck job; the production
|
||||||
frontend builder stage inside the production `Dockerfile`)
|
`Dockerfile` builds the frontend on `node:22-alpine`)
|
||||||
- ruff (lint job runs `ruff check src/` with zero install overhead)
|
- ruff (lint job runs `ruff check src/ scripts/` with zero install overhead)
|
||||||
- uv (test + integration jobs run `uv sync --locked`; installed in the
|
- uv (test + integration jobs run `uv sync --locked`)
|
||||||
image since the ci-python Dockerfile started pip-installing it)
|
|
||||||
- docker CLI + buildx (build job pushes the production image to the
|
- docker CLI + buildx (build job pushes the production image to the
|
||||||
Fabled-Git registry)
|
Fabled-Git registry)
|
||||||
|
|
||||||
@@ -30,24 +29,15 @@ Anything CI installs at job time that isn't in the image. Promotion
|
|||||||
candidates if more than one project needs them.
|
candidates if more than one project needs them.
|
||||||
|
|
||||||
- `shellcheck` — apt-installed in the **plugin** job, which lints the
|
- `shellcheck` — apt-installed in the **plugin** job, which lints the
|
||||||
Claude Code hook scripts and runs their fail-open smoke test. Per
|
Claude Code hook scripts and runs their fail-open smoke test. Scribe is
|
||||||
`docs/process.md`'s decision checkpoint, single-consumer deps stay
|
its only consumer, so per `docs/process.md` it stays per-job. It is small
|
||||||
per-job until a second consumer wants them; Scribe is the only one so
|
(~20 MB) and a promotion candidate once another project lints shell.
|
||||||
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.**
|
The hooks ship to users' machines, so they use only POSIX tools (awk, sed,
|
||||||
It was installed in two jobs, and this file used to record it as "load-
|
tr, od, cut, head, tail, grep, sort, date, printf) plus `git` and `curl`.
|
||||||
bearing for the smoke test specifically", because every hook opened
|
The smoke test runs on a bare image to keep it that way: a tool such as `jq`
|
||||||
`command -v jq || exit 0` and the test would otherwise pass while
|
is missing on macOS, slim Debian/Ubuntu and Alpine, and a hook that needed it
|
||||||
exercising nothing. That was the tail wagging the dog: the hooks ship to
|
would give those machines nothing.
|
||||||
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`.
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
+1
-29
@@ -9,22 +9,9 @@ services:
|
|||||||
SECURE_COOKIES: "true"
|
SECURE_COOKIES: "true"
|
||||||
networks:
|
networks:
|
||||||
- scribe_backend
|
- scribe_backend
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 30s
|
|
||||||
deploy:
|
|
||||||
restart_policy:
|
|
||||||
condition: on-failure
|
|
||||||
max_attempts: 5
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
# pgvector image (Debian/glibc, PG17) — bundles the `vector` extension that
|
# Postgres 17 with the vector extension Scribe needs.
|
||||||
# migration 0067 enables. Moved off postgres:16-alpine via logical
|
|
||||||
# dump/restore (which doubles as the PG16->PG17 major upgrade); see the
|
|
||||||
# TRANSITION runbook in the PR.
|
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
stop_grace_period: 120s
|
stop_grace_period: 120s
|
||||||
volumes:
|
volumes:
|
||||||
@@ -35,21 +22,6 @@ services:
|
|||||||
POSTGRES_DB: scribe
|
POSTGRES_DB: scribe
|
||||||
networks:
|
networks:
|
||||||
- scribe_backend
|
- scribe_backend
|
||||||
# Lenient by design: a transient host exec/healthcheck stall (incident:
|
|
||||||
# runc setns failures -> "unhealthy" -> SIGKILL -> crash loop) must never
|
|
||||||
# escalate to killing the DB. Health here only gates app startup order.
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U scribe"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 10
|
|
||||||
start_period: 180s
|
|
||||||
deploy:
|
|
||||||
restart_policy:
|
|
||||||
condition: on-failure
|
|
||||||
delay: 10s
|
|
||||||
max_attempts: 0
|
|
||||||
window: 120s
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
@@ -1,16 +1,7 @@
|
|||||||
# Fabled Scribe — Quick Start
|
# Fabled Scribe from the pre-built image:
|
||||||
#
|
# docker compose -f docker-compose.quickstart.yml up -d
|
||||||
# No build required. Pulls the latest pre-built image from the registry.
|
# Then open http://localhost:5000 (the first account registered becomes admin)
|
||||||
#
|
# and connect Claude from Settings → MCP Access.
|
||||||
# Usage:
|
|
||||||
# 1. Download this file
|
|
||||||
# 2. docker compose -f docker-compose.quickstart.yml up -d
|
|
||||||
# 3. Open http://localhost:5000 — the first account registered becomes admin
|
|
||||||
# 4. Go to Settings → MCP Access and connect Claude (Code or Desktop) via the
|
|
||||||
# bearer-token URL shown there.
|
|
||||||
#
|
|
||||||
# Set SECRET_KEY via environment variable or a .env file alongside this file:
|
|
||||||
# SECRET_KEY=your-random-secret-here
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
@@ -19,23 +10,17 @@ services:
|
|||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql+asyncpg://scribe:scribe@db:5432/scribe"
|
DATABASE_URL: "postgresql+asyncpg://scribe:scribe@db:5432/scribe"
|
||||||
|
# Set SECRET_KEY in the environment or a .env file beside this one.
|
||||||
SECRET_KEY: "${SECRET_KEY:-change-me-in-production}"
|
SECRET_KEY: "${SECRET_KEY:-change-me-in-production}"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
|
||||||
volumes:
|
volumes:
|
||||||
- app_data:/data
|
- app_data:/data
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
- db
|
||||||
condition: service_healthy
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 30s
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
# pgvector image (PG17) — bundles the `vector` extension (migration 0067).
|
# Postgres 17 with the vector extension Scribe needs.
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector:pg17
|
||||||
stop_grace_period: 120s
|
stop_grace_period: 120s
|
||||||
volumes:
|
volumes:
|
||||||
@@ -44,14 +29,6 @@ services:
|
|||||||
POSTGRES_USER: scribe
|
POSTGRES_USER: scribe
|
||||||
POSTGRES_PASSWORD: scribe
|
POSTGRES_PASSWORD: scribe
|
||||||
POSTGRES_DB: scribe
|
POSTGRES_DB: scribe
|
||||||
# Lenient by design: a transient host exec/healthcheck stall must never
|
|
||||||
# escalate to killing the DB. Health here only gates app startup order.
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U scribe"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 10
|
|
||||||
start_period: 180s
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+6
-20
@@ -4,27 +4,21 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
- db
|
||||||
condition: service_healthy
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- app_data:/data
|
- app_data:/data
|
||||||
# To use a bind mount instead (gives direct host access to all app data):
|
# Or a bind mount, for direct host access to the app's data:
|
||||||
# - ./data:/data
|
# - ./data:/data
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-scribe}:${POSTGRES_PASSWORD:-scribe}@db:5432/${POSTGRES_DB:-scribe}"
|
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-scribe}:${POSTGRES_PASSWORD:-scribe}@db:5432/${POSTGRES_DB:-scribe}"
|
||||||
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
|
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
|
||||||
# Uncomment if you have a SearXNG instance you want to surface in the
|
# Optional: a SearXNG instance to list as the web-search backend in Integrations.
|
||||||
# Integrations tab as a configured web-search backend:
|
|
||||||
# SEARXNG_URL: "http://searxng:8080"
|
# SEARXNG_URL: "http://searxng:8080"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 30s
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
# Postgres 17 with the vector extension Scribe needs.
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
stop_grace_period: 120s
|
stop_grace_period: 120s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
@@ -33,14 +27,6 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER:-scribe}
|
POSTGRES_USER: ${POSTGRES_USER:-scribe}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scribe}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scribe}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-scribe}
|
POSTGRES_DB: ${POSTGRES_DB:-scribe}
|
||||||
# Lenient by design: a transient host exec/healthcheck stall must never
|
|
||||||
# escalate to killing the DB. Health here only gates app startup order.
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scribe}"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 10
|
|
||||||
start_period: 180s
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
# Fable MCP — Design Spec
|
|
||||||
|
|
||||||
**Date:** 2026-03-23
|
|
||||||
**Status:** Approved
|
|
||||||
**Author:** bvandeusen + Claude
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
A Python MCP (Model Context Protocol) server that lets Claude directly interface with a running Fabled Assistant instance. The goal is two-fold: Claude's stronger reasoning handles planning, documentation, and analysis while Fable remains the authoritative store for notes, tasks, projects, and milestones; and direct API access enables process improvement by letting Claude observe and operate on real data rather than working from descriptions.
|
|
||||||
|
|
||||||
The work is split into two sub-projects:
|
|
||||||
|
|
||||||
1. **Fable API Key Feature** — additions to the main `scribe` project to support bearer token authentication
|
|
||||||
2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root
|
|
||||||
|
|
||||||
A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sub-project 1: Fable API Key Feature
|
|
||||||
|
|
||||||
### Motivation
|
|
||||||
|
|
||||||
All existing Fable routes use session-based authentication (`session["user_id"]`). The MCP server runs as a local process and cannot maintain a browser session, so a stateless bearer token mechanism is required. API keys are user-scoped and carry a read/write permission level.
|
|
||||||
|
|
||||||
### Database
|
|
||||||
|
|
||||||
New table `api_keys` via migration `0027_add_api_keys.py`:
|
|
||||||
- `down_revision = "0026"` (the briefing tables migration)
|
|
||||||
- `revision = "0027"`
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|--------|------|-------|
|
|
||||||
| `id` | Integer PK | |
|
|
||||||
| `user_id` | Integer FK → users | CASCADE delete |
|
|
||||||
| `name` | Text | Human-readable label |
|
|
||||||
| `key_hash` | Text | SHA-256 of the full key, used for lookup |
|
|
||||||
| `key_prefix` | Text | First 8 chars (e.g. `fmcp_ab12`), shown in UI |
|
|
||||||
| `scope` | Text | `"read"` or `"write"` |
|
|
||||||
| `last_used_at` | Timestamp | Nullable, updated on each authenticated request |
|
|
||||||
| `created_at` | Timestamp | |
|
|
||||||
| `revoked_at` | Timestamp | Nullable — soft delete |
|
|
||||||
|
|
||||||
Full keys are generated as `fmcp_<32 random url-safe chars>`, returned once at creation, never stored. Only the SHA-256 hash is persisted.
|
|
||||||
|
|
||||||
### Auth Middleware (`auth.py`)
|
|
||||||
|
|
||||||
`_check_auth` is updated to check the `Authorization: Bearer <token>` header before falling back to session auth:
|
|
||||||
|
|
||||||
1. If header present: hash the token, look up in `api_keys` where `revoked_at IS NULL`
|
|
||||||
2. If found: update `last_used_at`, set `g.user` and `g.api_key`
|
|
||||||
3. Scope enforcement: if `g.api_key.scope == "read"` and `request.method` is not `GET`, return 403
|
|
||||||
4. If header absent: existing session logic runs unchanged
|
|
||||||
|
|
||||||
Session auth is untouched — no regression risk for the web UI.
|
|
||||||
|
|
||||||
**Admin routes and API keys:** Routes decorated with `admin_required` check `user.role == "admin"` after auth resolves. API keys authenticate as the key owner — so a write-scoped key for a non-admin user will fail admin-protected routes with 403 (role check, not scope check). This is intentional: API keys cannot elevate privilege beyond the user's role.
|
|
||||||
|
|
||||||
### Routes (`/api/api-keys`)
|
|
||||||
|
|
||||||
New blueprint `api_keys_bp`, registered in `app.py`:
|
|
||||||
|
|
||||||
- `GET /api/api-keys` — list caller's keys (prefix, name, scope, last_used_at, created_at — never the hash or full key)
|
|
||||||
- `POST /api/api-keys` — create key; body: `{name, scope}`. Returns full key in response **once only**
|
|
||||||
- `DELETE /api/api-keys/:id` — revoke by setting `revoked_at = now()`
|
|
||||||
|
|
||||||
### Settings UI
|
|
||||||
|
|
||||||
New "API Keys" tab in `SettingsView.vue` (added to `VALID_TABS`):
|
|
||||||
|
|
||||||
- Create form: name input + read/write radio/toggle + "Generate Key" button
|
|
||||||
- After creation: one-time modal displaying the full key with a copy button and a warning that it will not be shown again
|
|
||||||
- Keys table: columns for name, scope badge, prefix, last used, revoke button
|
|
||||||
- Revoke shows inline confirmation before calling DELETE
|
|
||||||
|
|
||||||
### New Search Endpoint
|
|
||||||
|
|
||||||
`GET /api/search?q=<query>&content_type=note|task|all&limit=N`
|
|
||||||
|
|
||||||
Calls the existing `semantic_search_notes()` service (already in `services/embeddings.py`). The `content_type` parameter maps to the service's `is_task` argument:
|
|
||||||
- `content_type=note` → `is_task=False`
|
|
||||||
- `content_type=task` → `is_task=True`
|
|
||||||
- `content_type=all` (default) → `is_task=None`
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"results": [
|
|
||||||
{"id": 1, "title": "...", "body": "...", "is_task": false, "similarity": 0.87, "tags": [...]}
|
|
||||||
],
|
|
||||||
"total": 5
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This endpoint is needed by the MCP's `search.py` tool module. It reuses existing infrastructure with no new ML work.
|
|
||||||
|
|
||||||
### Conversation Type: `"mcp"`
|
|
||||||
|
|
||||||
A new conversation type `"mcp"` is added alongside `"chat"` and `"briefing"`. This requires changes in two places in the main app:
|
|
||||||
|
|
||||||
**`services/chat.py`** — `create_conversation(user_id, title, model)` gains an optional `conversation_type: str = "chat"` parameter, passed through to the `Conversation` constructor.
|
|
||||||
|
|
||||||
**`routes/chat.py`** — `POST /api/chat/conversations` accepts an optional `conversation_type` body field (defaults to `"chat"`), passed to `create_conversation`. `GET /api/chat/conversations` continues to default-filter to `conversation_type="chat"` (excluding `"mcp"` and `"briefing"`); pass `?type=mcp` to retrieve MCP conversations.
|
|
||||||
|
|
||||||
**Retention:** `cleanup_old_conversations` in `routes/chat.py` currently deletes conversations regardless of type. It must be updated to exclude `conversation_type="mcp"` from the sweep, so MCP audit-trail conversations are not automatically pruned.
|
|
||||||
|
|
||||||
MCP conversations:
|
|
||||||
- Are created with a caller-supplied name (e.g., `"MCP Session 2026-03-23"`) or auto-named
|
|
||||||
- Are excluded from the default chat list
|
|
||||||
- Are accessible via `GET /api/chat/conversations?type=mcp` for inspection
|
|
||||||
- Appear in the Fable UI if explicitly navigated to, providing an audit trail of MCP-driven interactions
|
|
||||||
|
|
||||||
### Chat SSE Wire Format
|
|
||||||
|
|
||||||
The MCP's `chat.py` tool must consume the existing SSE stream. The relevant endpoints and event schema:
|
|
||||||
|
|
||||||
- **Create conversation:** `POST /api/chat/conversations` → `{id, title, ...}`
|
|
||||||
- **Post message + start generation:** `POST /api/chat/conversations/:id/messages` → `{message_id, ...}`; then connect to:
|
|
||||||
- **Stream:** `GET /api/chat/conversations/:id/stream` (SSE)
|
|
||||||
|
|
||||||
SSE event format (each line is `data: <json>`):
|
|
||||||
- `{"type": "token", "content": "..."}` — streaming token
|
|
||||||
- `{"type": "tool_call", "name": "...", "result": {...}}` — tool fired
|
|
||||||
- `{"type": "done", "content": "...", "tools_used": [...]}` — generation complete; this is the termination event
|
|
||||||
|
|
||||||
The MCP tool reads tokens until it receives `type: "done"`, then returns `response` (full content) and `tools_used` (list of tool names).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sub-project 2: Fable MCP Server
|
|
||||||
|
|
||||||
### Location
|
|
||||||
|
|
||||||
`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `scribe`. It will be extracted to its own Forgejo repo once stable.
|
|
||||||
|
|
||||||
### Package Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
fable-mcp/
|
|
||||||
pyproject.toml # entry point: fable-mcp = "fable_mcp.server:main"
|
|
||||||
README.md
|
|
||||||
.env.example # FABLE_URL=http://localhost:8080, FABLE_API_KEY=fmcp_...
|
|
||||||
fable_mcp/
|
|
||||||
__init__.py
|
|
||||||
server.py # FastMCP instance, imports + registers all tool modules
|
|
||||||
client.py # FableClient: async httpx wrapper, env var config, FableAPIError
|
|
||||||
tools/
|
|
||||||
__init__.py
|
|
||||||
notes.py # list_notes, get_note, create_note, update_note, delete_note
|
|
||||||
tasks.py # list_tasks, get_task, create_task, update_task, delete_task,
|
|
||||||
# patch_task_status, add_task_log
|
|
||||||
projects.py # list_projects, get_project, create_project, update_project,
|
|
||||||
# delete_project, get_project_summary
|
|
||||||
milestones.py # list_milestones, get_milestone, create_milestone,
|
|
||||||
# update_milestone, delete_milestone
|
|
||||||
search.py # semantic_search — calls GET /api/search
|
|
||||||
chat.py # send_message — creates/continues conversation, returns response
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[project]
|
|
||||||
dependencies = [
|
|
||||||
"mcp[cli]>=1.0",
|
|
||||||
"httpx>=0.27",
|
|
||||||
"python-dotenv>=1.0",
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### `client.py`
|
|
||||||
|
|
||||||
`FableClient` is an async context manager wrapping `httpx.AsyncClient`. It reads `FABLE_URL` and `FABLE_API_KEY` from environment variables (with `python-dotenv` fallback to `.env`). All methods are `async def` and return parsed dicts. A `FableAPIError(status_code, message)` exception is raised for any non-2xx response.
|
|
||||||
|
|
||||||
A module-level singleton `_client: FableClient` is initialized at server startup and shared across all tool modules.
|
|
||||||
|
|
||||||
### `server.py`
|
|
||||||
|
|
||||||
Uses `mcp[cli]`'s `FastMCP` class. Imports all tool modules, which register their tools via decorators against the shared `FastMCP` instance. Entry point `main()` calls `mcp.run()` (stdio transport).
|
|
||||||
|
|
||||||
### Tool Modules
|
|
||||||
|
|
||||||
Each module imports `_client` from `client.py` and defines tools as `async def` functions decorated with `@mcp.tool()`. Tool docstrings serve as the MCP tool descriptions visible to Claude.
|
|
||||||
|
|
||||||
**`notes.py`** tools:
|
|
||||||
- `list_notes(query, tags, project_id, limit, offset)`
|
|
||||||
- `get_note(note_id)`
|
|
||||||
- `create_note(title, body, tags, project_id)`
|
|
||||||
- `update_note(note_id, title, body, tags, project_id)`
|
|
||||||
- `delete_note(note_id)`
|
|
||||||
|
|
||||||
**`tasks.py`** tools:
|
|
||||||
- `list_tasks(query, project_id, milestone_id, status, priority, limit, offset)`
|
|
||||||
- `get_task(task_id)`
|
|
||||||
- `create_task(title, body, project_id, milestone_id, priority, status)`
|
|
||||||
- `update_task(task_id, title, body, project_id, milestone_id, priority, status)`
|
|
||||||
- `delete_task(task_id)`
|
|
||||||
- `patch_task_status(task_id, status)`
|
|
||||||
- `add_task_log(task_id, content)`
|
|
||||||
|
|
||||||
**`projects.py`** tools:
|
|
||||||
- `list_projects()`
|
|
||||||
- `get_project(project_id)` — includes milestone summary
|
|
||||||
- `create_project(title, description, goal, color)`
|
|
||||||
- `update_project(project_id, title, description, goal, status, color)`
|
|
||||||
- `delete_project(project_id)`
|
|
||||||
|
|
||||||
**`milestones.py`** tools:
|
|
||||||
- `list_milestones(project_id, status)`
|
|
||||||
- `get_milestone(project_id, milestone_id)`
|
|
||||||
- `create_milestone(project_id, title, description, order_index)`
|
|
||||||
- `update_milestone(project_id, milestone_id, title, description, status, order_index)`
|
|
||||||
- `delete_milestone(project_id, milestone_id)`
|
|
||||||
|
|
||||||
**`search.py`** tools:
|
|
||||||
- `semantic_search(query, content_type, limit)` — `content_type` is `"note"`, `"task"`, or `"all"` (avoids shadowing Python's `type` builtin). Calls `GET /api/search`, returns ranked results with similarity scores.
|
|
||||||
|
|
||||||
**`chat.py`** tools:
|
|
||||||
- `send_message(message, conversation_name)`:
|
|
||||||
- Looks up or creates a Fable conversation with the given name and `conversation_type="mcp"`
|
|
||||||
- Posts the message via `POST /api/chat/conversations/:id/messages`
|
|
||||||
- Consumes SSE stream from `GET /api/chat/conversations/:id/stream` until `type: "done"`
|
|
||||||
- Returns: `{response: str, tools_used: [str], conversation_id: int}`
|
|
||||||
- MCP conversations are excluded from the normal chat UI list
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
`FableAPIError` is caught at each tool boundary and returned as a descriptive string rather than propagating as an exception. This ensures Claude receives a readable error message (e.g., `"Fable API error 403: Read-only key cannot perform write operations."`) rather than a stack trace. Network errors (`httpx.RequestError`) are similarly caught and surfaced as strings.
|
|
||||||
|
|
||||||
Scope enforcement lives entirely in Fable's auth middleware — the MCP does not duplicate it.
|
|
||||||
|
|
||||||
### Claude Code Registration
|
|
||||||
|
|
||||||
After `pip install -e fable-mcp/` (or `uv tool install ./fable-mcp`), add to `~/.claude/settings.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"fable": {
|
|
||||||
"command": "fable-mcp",
|
|
||||||
"env": {
|
|
||||||
"FABLE_URL": "http://localhost:8080",
|
|
||||||
"FABLE_API_KEY": "fmcp_your_key_here"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Claude Code spawns the process over stdio automatically. No Docker, no daemon.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Build & Repo Plan
|
|
||||||
|
|
||||||
1. Implement and test within `scribe/fable-mcp/`
|
|
||||||
2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`)
|
|
||||||
3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of Scope (v1)
|
|
||||||
|
|
||||||
- MCP resources (browsable URI tree) — tools cover all use cases in Claude Code
|
|
||||||
- Forgejo MCP — follow-on spec
|
|
||||||
- Rate limiting on API key endpoints
|
|
||||||
- Key expiry / TTL
|
|
||||||
- Per-resource scope (e.g., key restricted to one project)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,200 +0,0 @@
|
|||||||
# Speech-to-Speech (S2S) Design Spec
|
|
||||||
|
|
||||||
**Branch:** `feature/voice-s2s`
|
|
||||||
**Date:** 2026-03-29
|
|
||||||
**Status:** Approved for implementation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
- **STT:** faster-whisper (in-process Python, model size configurable via `STT_MODEL` env var)
|
|
||||||
- **TTS:** Kokoro TTS (in-process Python, voice/speed/style configurable per-user)
|
|
||||||
- **Input mode:** Push-to-talk (Phase 1); VAD deferred
|
|
||||||
- **No browser STT/TTS fallbacks** — self-hosted only, data stays on-server
|
|
||||||
- **No Android app** — web only for this implementation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New Env Vars (`config.py`)
|
|
||||||
|
|
||||||
| Var | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `VOICE_ENABLED` | `false` | Feature flag — opt-in |
|
|
||||||
| `STT_BACKEND` | `faster-whisper` | Only supported value currently |
|
|
||||||
| `STT_MODEL` | `base.en` | `tiny.en` / `base.en` / `small.en` / `medium.en` |
|
|
||||||
| `TTS_BACKEND` | `kokoro` | Only supported value currently |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Per-User Settings (stored in `settings` table)
|
|
||||||
|
|
||||||
| Key | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `voice_tts_voice` | `af_heart` | Kokoro voice ID |
|
|
||||||
| `voice_tts_speed` | `1.0` | Speech rate, 0.7–1.3 |
|
|
||||||
| `voice_speech_style` | `conversational` | `conversational` / `concise` / `detailed` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New Backend Files
|
|
||||||
|
|
||||||
### `src/scribe/services/stt.py`
|
|
||||||
Lazy singleton `WhisperModel` loader. Public API:
|
|
||||||
- `load_stt_model()` — called at startup via `asyncio.create_task`
|
|
||||||
- `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text
|
|
||||||
- `stt_available() -> bool`
|
|
||||||
|
|
||||||
### `src/scribe/services/tts.py`
|
|
||||||
Lazy singleton `KPipeline` loader. Public API:
|
|
||||||
- `load_tts_model()` — called at startup
|
|
||||||
- `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono)
|
|
||||||
- `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels
|
|
||||||
- `tts_available() -> bool`
|
|
||||||
|
|
||||||
### `src/scribe/routes/voice.py`
|
|
||||||
Blueprint at `/api/voice`, all routes `@login_required`.
|
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `/api/voice/status` | GET | STT/TTS availability; `enabled` false if `VOICE_ENABLED=false` |
|
|
||||||
| `/api/voice/voices` | GET | List available Kokoro voices |
|
|
||||||
| `/api/voice/transcribe` | POST | multipart `audio` field → `{"transcript": "...", "duration_ms": 123}` |
|
|
||||||
| `/api/voice/synthesise` | POST | `{"text", "voice", "speed"}` → WAV bytes |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Modified Backend Files
|
|
||||||
|
|
||||||
### `src/scribe/app.py`
|
|
||||||
- Register `voice_bp` blueprint
|
|
||||||
- In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED`
|
|
||||||
|
|
||||||
### `src/scribe/config.py`
|
|
||||||
- Add 4 new env var attributes
|
|
||||||
- Add validation in `validate()`
|
|
||||||
|
|
||||||
### `src/scribe/services/llm.py`
|
|
||||||
- Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()`
|
|
||||||
- When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."*
|
|
||||||
- Append style modifier based on `voice_speech_style`
|
|
||||||
|
|
||||||
### `src/scribe/services/generation_task.py`
|
|
||||||
- Add `voice_mode: bool = False` to `run_generation()`
|
|
||||||
- Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()`
|
|
||||||
|
|
||||||
### `src/scribe/routes/chat.py`
|
|
||||||
- Allow `"voice"` in `conversation_type` whitelist
|
|
||||||
|
|
||||||
### `src/scribe/services/chat.py`
|
|
||||||
- Exclude `conversation_type == "voice"` from auto-cleanup retention
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New Frontend Files
|
|
||||||
|
|
||||||
### `frontend/src/composables/useVoiceRecorder.ts`
|
|
||||||
Wraps `MediaRecorder`. Exports: `recording`, `error`, `isSupported`, `startRecording()`, `stopRecording() -> Promise<Blob>`.
|
|
||||||
|
|
||||||
### `frontend/src/composables/useVoiceAudio.ts`
|
|
||||||
Wraps `AudioContext`. Exports: `playing`, `isSupported`, `play(blob)`, `stop()`.
|
|
||||||
|
|
||||||
### `frontend/src/components/VoiceOverlay.vue`
|
|
||||||
Floating PTT button (fixed bottom-right). Creates/reuses a `"voice"` conversation. Full flow: record → transcribe → send → stream → synthesise → play. Space bar hotkey (from `App.vue`). Mounted globally in `App.vue`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Modified Frontend Files
|
|
||||||
|
|
||||||
### `frontend/src/api/client.ts`
|
|
||||||
Add: `transcribeAudio(blob)`, `synthesiseSpeech(text, voice?, speed?)`, `getVoiceStatus()`, `getVoiceList()`
|
|
||||||
|
|
||||||
### `frontend/src/views/BriefingView.vue`
|
|
||||||
- "Listen" button: reads latest assistant message aloud via TTS
|
|
||||||
- Mic button in input bar: PTT → transcribe → auto-fill input → send
|
|
||||||
- Auto-TTS on assistant response when in listen mode
|
|
||||||
|
|
||||||
### `frontend/src/views/SettingsView.vue`
|
|
||||||
- New "Voice" tab: voice dropdown, speed slider, speech style radio
|
|
||||||
- Loads from `/api/settings`, saves via `PUT /api/settings`
|
|
||||||
|
|
||||||
### `frontend/src/App.vue`
|
|
||||||
- Mount `<VoiceOverlay />`
|
|
||||||
- Space bar → `"voice:ptt-toggle"` custom event
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Audio Format
|
|
||||||
|
|
||||||
| Direction | Format | Rationale |
|
|
||||||
|---|---|---|
|
|
||||||
| Browser → Server | WebM/Opus | Native `MediaRecorder` output; no re-encoding |
|
|
||||||
| Server → Browser | WAV (24kHz, 16-bit mono) | Kokoro native; no re-encoding; `decodeAudioData` compatible |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies to Add (`pyproject.toml`)
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[project.optional-dependencies]
|
|
||||||
voice = [
|
|
||||||
"faster-whisper>=1.0",
|
|
||||||
"kokoro>=0.9",
|
|
||||||
"soundfile>=0.12",
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Install unconditionally in Docker (activated by `VOICE_ENABLED` at runtime):
|
|
||||||
```dockerfile
|
|
||||||
RUN pip install faster-whisper kokoro soundfile
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database Migration
|
|
||||||
|
|
||||||
No schema changes required. `conversation_type` is unconstrained TEXT. Voice settings use existing key-value `settings` table. Optional no-op migration `0034_voice_conversation_type.py` for audit trail.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Phases
|
|
||||||
|
|
||||||
### Phase 1 — Backend services + routes
|
|
||||||
1. Add env vars to `config.py`
|
|
||||||
2. Create `services/stt.py` (faster-whisper)
|
|
||||||
3. Create `services/tts.py` (Kokoro)
|
|
||||||
4. Create `routes/voice.py` (4 endpoints)
|
|
||||||
5. Wire model loading into `app.py` startup
|
|
||||||
6. Add `voice_mode` to `build_context()` + `run_generation()`
|
|
||||||
7. Allow `"voice"` conversation type in chat route + cleanup exclusion
|
|
||||||
|
|
||||||
### Phase 2 — BriefingView listen + voice follow-up
|
|
||||||
1. Create `useVoiceRecorder.ts`
|
|
||||||
2. Create `useVoiceAudio.ts`
|
|
||||||
3. Add voice API functions to `client.ts`
|
|
||||||
4. Add "Listen" button + mic button to `BriefingView.vue`
|
|
||||||
|
|
||||||
### Phase 3 — VoiceOverlay for general voice chat
|
|
||||||
1. Create `VoiceOverlay.vue`
|
|
||||||
2. Mount in `App.vue` + Space bar hotkey
|
|
||||||
|
|
||||||
### Phase 4 — Settings UI
|
|
||||||
1. Add "Voice" tab to `SettingsView.vue`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Kokoro Voice Reference
|
|
||||||
|
|
||||||
| ID | Character |
|
|
||||||
|---|---|
|
|
||||||
| `af_heart` | American female, warm (recommended default) |
|
|
||||||
| `af_bella` | American female, expressive |
|
|
||||||
| `af_nicole` | American female, breathy/intimate |
|
|
||||||
| `af_sarah` | American female, clear |
|
|
||||||
| `af_sky` | American female, bright |
|
|
||||||
| `am_adam` | American male, neutral |
|
|
||||||
| `am_michael` | American male, deeper |
|
|
||||||
| `bf_emma` | British female |
|
|
||||||
| `bf_isabella` | British female, formal |
|
|
||||||
| `bm_george` | British male |
|
|
||||||
| `bm_lewis` | British male, casual |
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
# Agentic Briefing — Design Spec
|
|
||||||
|
|
||||||
**Date:** 2026-04-10
|
|
||||||
**Status:** Proposed
|
|
||||||
**Author:** bvandeusen + Claude
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The current briefing pipeline hallucinates calendar events, tasks, and news items that do not exist in the user's actual data. Observed examples from production:
|
|
||||||
|
|
||||||
- A morning briefing asserting "your dentist appointment is still in progress" when no such event existed
|
|
||||||
- An 8am check-in mentioning "a quick meeting at 10:30 AM" with no backing calendar entry
|
|
||||||
- A midday check-in inventing "a team huddle at 2:30 PM" and "the Q2 budget draft due by Friday"
|
|
||||||
- The model calling `search_images` in response to a clarifying question about a fabricated meeting
|
|
||||||
|
|
||||||
These are not bugs in data retrieval — the data gathering code (`_gather_internal`, `_gather_external`) returns correct values. They are a structural consequence of how the briefing context is assembled.
|
|
||||||
|
|
||||||
## Root cause — the "no receipt" problem
|
|
||||||
|
|
||||||
The current `run_compilation` pipeline does this:
|
|
||||||
|
|
||||||
1. Python code gathers data (tasks, events, weather, news) from the database
|
|
||||||
2. Python formats the data into a structured text blob as the user-role message: `TODAY'S EVENTS: ...`, `DUE TODAY: ...`, etc.
|
|
||||||
3. The LLM is called **once** with `[system_prompt, user_prompt]` and produces prose
|
|
||||||
4. Only the prose reply is written to the conversation. **The underlying data is never persisted in the conversation history.**
|
|
||||||
|
|
||||||
When the user later chats in the briefing conversation, the chat endpoint loads the full conversation history — which contains the model's *prose* from earlier but not the data that prose was derived from. The model's own prior output becomes the only source of "truth" available for follow-up questions. If that prose asserted a fact (real or hallucinated), the model has no way to distinguish it from ground truth when generating the next reply, and it will double down.
|
|
||||||
|
|
||||||
Compounding factors:
|
|
||||||
|
|
||||||
- **Empty sections are silently omitted.** If `calendar_events` is empty, the user prompt contains no `TODAY'S EVENTS:` line at all. The model has no explicit "zero events" signal — combined with an imperative system prompt ("note calendar events and tasks"), it interprets the silence as "I should mention some" and fabricates.
|
|
||||||
- **Scheduled slot injections append synthetic turns.** `run_slot_injection` writes a fake `[Midday briefing update]` user message and the assistant reply into the persistent conversation. By evening, the chat history contains three separate briefings, each potentially with errors, all treated as equal-weight context on follow-up.
|
|
||||||
- **The `search_images` tool is available during briefing chat**, with a negative-instruction description ("Not for factual questions"). Small and mid-sized models frequently ignore negative guidance in tool descriptions and call the tool anyway.
|
|
||||||
|
|
||||||
## Solution — agentic briefing (the receipt model)
|
|
||||||
|
|
||||||
Replace the one-shot synthesis with a tool-call loop. The briefing is no longer "a text blob synthesized from pre-gathered data." It is **a scheduled agent run**: the LLM is given a system prompt, a curated set of read-only data tools, and a trigger ("generate the morning briefing"). The model must call tools to see what exists. Every tool call and tool result becomes part of the conversation history, where it lives as a permanent, structured receipt.
|
|
||||||
|
|
||||||
### Why this fixes the hallucination
|
|
||||||
|
|
||||||
When the model calls `list_events(today) → []`, that empty array is now a persistent message in the conversation. On a follow-up question like "what meeting?", the chat endpoint loads the conversation and the model sees its own tool result from the morning showing no events. Answering "you had a meeting at 10:30" would require the model to directly contradict a tool result sitting two messages back — something LLMs are much more reliable at avoiding than contradicting their own prior prose.
|
|
||||||
|
|
||||||
In short: **the model cannot fabricate what has a visible receipt proving it does not exist.**
|
|
||||||
|
|
||||||
### What the model sees — before vs after
|
|
||||||
|
|
||||||
**Before** — the model sees one structured blob and produces prose. The blob is discarded.
|
|
||||||
|
|
||||||
```
|
|
||||||
[system] You are a personal assistant... weave together what matters...
|
|
||||||
[user] Date: 2026-04-10
|
|
||||||
WEATHER: Home — partly cloudy, 8–16°C
|
|
||||||
DUE TODAY: Fix briefing hallucinations
|
|
||||||
OVERDUE (2 tasks): ...
|
|
||||||
[assistant] Morning! Looks like a quiet day with two overdue items...
|
|
||||||
```
|
|
||||||
|
|
||||||
**After** — the model is given tools and iterates to ground truth.
|
|
||||||
|
|
||||||
```
|
|
||||||
[system] You are the user's personal assistant giving their morning briefing.
|
|
||||||
Use tools to see what's relevant. Only mention things you learned from
|
|
||||||
tool calls. If a tool returns nothing, say so honestly.
|
|
||||||
[user] Generate the morning briefing.
|
|
||||||
[assistant] tool_call: list_tasks(filter="due_today")
|
|
||||||
[tool] [{"id": 81, "title": "Fix briefing hallucinations", ...}]
|
|
||||||
[assistant] tool_call: list_tasks(filter="overdue")
|
|
||||||
[tool] [{"id": 42, ...}, {"id": 43, ...}]
|
|
||||||
[assistant] tool_call: list_events(today)
|
|
||||||
[tool] []
|
|
||||||
[assistant] tool_call: get_weather(location="home")
|
|
||||||
[tool] {"description": "partly cloudy", "temp_min": 8, "temp_max": 16}
|
|
||||||
[assistant] tool_call: get_rss_items(max=8)
|
|
||||||
[tool] [...]
|
|
||||||
[assistant] Morning! Partly cloudy today, 8 to 16 — nothing on the calendar
|
|
||||||
so it's a clean run at the desk. Two things to keep in mind...
|
|
||||||
```
|
|
||||||
|
|
||||||
The conversation now contains verifiable receipts: `list_events(today)` returned `[]`, and that result sits in context forever (until pruned). Follow-up questions operate against those receipts.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Existing infrastructure (reused, not rebuilt)
|
|
||||||
|
|
||||||
The codebase already has the agentic primitives — they're used for regular chat:
|
|
||||||
|
|
||||||
- **`llm.py::stream_chat_with_tools`** — the streaming tool-use loop that talks to Ollama with a `tools` parameter and yields tool-call chunks
|
|
||||||
- **`generation_task.py::_stream_with_retry`** — wraps `stream_chat_with_tools` with retry-on-500 behavior for cold-model races
|
|
||||||
- **`tools.py`** — defines 40+ tool schemas and an execution dispatcher
|
|
||||||
|
|
||||||
The briefing bypasses all of this and calls a one-shot `_llm_synthesise` helper. The refactor is mainly "route briefings through the same pipeline regular chat already uses."
|
|
||||||
|
|
||||||
### New modules
|
|
||||||
|
|
||||||
**`briefing_tools.py`** — a small wrapper exposing a curated read-only subset of `tools.py` for briefing runs. This is an **explicit allowlist**, not a blocklist, so newly-added tools must be opted in:
|
|
||||||
|
|
||||||
| Tool | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `list_tasks` (with filter args) | See what's actionable today, overdue, high priority |
|
|
||||||
| `list_events` (today / upcoming) | Know what's on the calendar |
|
|
||||||
| `get_weather` | Current/forecast weather |
|
|
||||||
| `get_rss_items` | Pull news themes filtered by user preferences |
|
|
||||||
| `list_projects` | Understand project context |
|
|
||||||
| `search_projects` | Surface active project summaries |
|
|
||||||
| `list_notes` (recent) | Capture follow-ups from yesterday |
|
|
||||||
|
|
||||||
**Explicitly omitted** from the briefing tool set:
|
|
||||||
|
|
||||||
- `search_images`, `search_web`, `research_topic`, `read_article` — external search is not a briefing concern, and `search_images` is the source of the "Peter Kyle Science Secretary" image-search bug
|
|
||||||
- All `create_*`, `update_*`, `delete_*` — briefings are read-only; a scheduled background job must not decide to mutate the user's data on its own
|
|
||||||
- `set_rag_scope`, `calculate` — not relevant to briefing content
|
|
||||||
|
|
||||||
### New briefing function
|
|
||||||
|
|
||||||
**`briefing_pipeline.py::run_agentic_briefing(user_id, slot, model, conversation_id)`** replaces `run_compilation`'s body (and eventually `run_slot_injection`). Internally:
|
|
||||||
|
|
||||||
1. Build a slot-specific system prompt (see below)
|
|
||||||
2. Load the curated briefing tools from `briefing_tools.py`
|
|
||||||
3. Seed messages with `[system, user]` where the user message is a simple trigger like `"Generate the morning briefing."`
|
|
||||||
4. Enter a tool-call loop (max 8 iterations):
|
|
||||||
- Call `stream_chat_with_tools`
|
|
||||||
- If the model returns `tool_calls`, execute them via the existing dispatcher, append tool results, continue
|
|
||||||
- If the model returns a final assistant message with no pending tool calls, break
|
|
||||||
5. Return `(final_prose, full_message_list, metadata)`
|
|
||||||
|
|
||||||
The full message list is important: it's written to the conversation along with the final prose, so the tool-call receipts become part of the persistent record.
|
|
||||||
|
|
||||||
### Slot-specific system prompts
|
|
||||||
|
|
||||||
Compilation (full morning briefing):
|
|
||||||
|
|
||||||
```
|
|
||||||
You are the user's personal assistant giving their full morning briefing.
|
|
||||||
Use the tools available to see what's actually relevant today — tasks due,
|
|
||||||
overdue items, events on the calendar, weather, news themes, project state
|
|
||||||
— and weave it into a warm, natural-sounding summary.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Call tools to see the data. Never assert facts you didn't learn from a tool.
|
|
||||||
- If a tool returns nothing (no events today, no overdue tasks), say so
|
|
||||||
honestly. Don't fabricate items to fill space.
|
|
||||||
- Write flowing prose. No markdown, no headers, no bullet points.
|
|
||||||
- Aim for 6–10 sentences. Skip topics that have nothing interesting.
|
|
||||||
- Close on one or two concrete, actionable suggestions.
|
|
||||||
|
|
||||||
User profile (for tone and preferences):
|
|
||||||
{profile_body}
|
|
||||||
```
|
|
||||||
|
|
||||||
Check-ins (midday, afternoon):
|
|
||||||
|
|
||||||
```
|
|
||||||
You are the user's personal assistant giving a brief {slot} check-in.
|
|
||||||
Use tools to see what's changed since this morning. Focus on progress
|
|
||||||
and what's still unaddressed.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Call tools to see current state. Never assert facts without tool results.
|
|
||||||
- If nothing meaningful has changed, say so briefly — don't invent progress.
|
|
||||||
- 3–5 sentences, natural prose, no markdown.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Conversation hygiene — removing the fake user messages
|
|
||||||
|
|
||||||
The current scheduler appends two fake messages on every slot injection:
|
|
||||||
|
|
||||||
```python
|
|
||||||
await post_message(conv.id, "user", f"[{slot.title()} briefing update]")
|
|
||||||
await post_message(conv.id, "assistant", text)
|
|
||||||
```
|
|
||||||
|
|
||||||
Under the agentic model, we drop the fake `"user"` message entirely. Slot updates become plain assistant messages tagged with `metadata.briefing_slot = "midday"`. The chat endpoint's message loader is updated to filter these when building the LLM context on follow-ups, so a user chatting in the briefing conversation doesn't see three earlier briefings smashed into their history. The UI continues to show them as visible timeline entries.
|
|
||||||
|
|
||||||
(This is the Option A filter from the earlier discussion — a small, surgical change compared to migrating briefings to a separate sidecar table. Sidecar storage remains a possible future step.)
|
|
||||||
|
|
||||||
### Ollama setup compatibility
|
|
||||||
|
|
||||||
The existing Ollama deployment uses non-parallel mode across two GPUs, with a concern about context duplication between cards. This is the correct setup for agentic briefings:
|
|
||||||
|
|
||||||
- Each tool-call iteration shares the same KV cache on the same GPU, so appending tool results is cheap
|
|
||||||
- There is no race where a second iteration could land on a different card with a different cache state
|
|
||||||
- The trade-off is serialization: a briefing in progress will block a concurrent user chat request until it finishes, but scheduled briefings are rare enough (4× per day) that this is acceptable
|
|
||||||
|
|
||||||
If GPU contention becomes a problem later, the right lever is pinning specific models to specific GPUs (e.g., background tasks on GPU 1, interactive models on GPU 0) — not enabling parallelism.
|
|
||||||
|
|
||||||
## Cost & trade-offs
|
|
||||||
|
|
||||||
- **Latency:** a briefing now makes 5–7 inference calls (one per tool-call decision plus the final prose) instead of 1. On a local Ollama with a 7B model, expect 15–40s per briefing vs the current 5–10s. Acceptable for a background job; if it becomes painful at the user-facing check-in slots, investigate letting the model batch independent tool calls in a single turn (Ollama supports this).
|
|
||||||
- **Model requirements:** the default model must be reliable at tool calling. `qwen2.5:7b`, `llama3.1:8b`, `mistral-small-3:24b`, and similar handle it well. Models in the ≤3B class typically fail — they either emit no tool calls and return empty prose, or hallucinate invalid tool arguments. If a user's `default_model` is too small, agentic mode should fall back to legacy mode with a log warning.
|
|
||||||
- **Context growth:** tool results bloat the conversation. At 8 tool calls per compilation × 4 slots per day, a daily briefing conversation can reach 20-30 KB of JSON-heavy history. Fine for a day; aged briefings should be archived/rolled up after 7 days.
|
|
||||||
- **Tool subset drift:** someone adds a new mutating tool to `tools.py` and forgets to update the briefing allowlist. Mitigated by the allowlist model — the default for new tools is "not exposed to briefings."
|
|
||||||
- **Infinite loop safety:** a buggy model could tool-call forever. Hard cap at 8 iterations, log a warning if hit, return whatever prose was last produced (or a fallback message).
|
|
||||||
|
|
||||||
## Migration path
|
|
||||||
|
|
||||||
Ship incrementally, each step independently reversible:
|
|
||||||
|
|
||||||
**PR 1 — Agentic compilation behind a feature flag.** Add `briefing_tools.py`, add `run_agentic_briefing`, add a per-user setting `briefing_mode: "legacy" | "agentic"` (default legacy). Route only the 4am compilation through the new path when the flag is set. Keep slot-injection on the legacy path. Enable the flag on the author's account first, validate output quality over several days, then flip the default for all users. No DB migration required — the setting lives in the existing `settings` table.
|
|
||||||
|
|
||||||
**PR 2 — Agentic slot injections + conversation hygiene.** Migrate midday/afternoon check-ins to the same pipeline. Remove the fake `[Midday briefing update]` user-role message; slot updates become plain assistant messages tagged with `metadata.briefing_slot`. Add a chat-message-loader filter that excludes slot-tagged messages from the LLM context on follow-ups (they remain visible in the UI).
|
|
||||||
|
|
||||||
**PR 3 — UI polish.** Collapsed tool-call status row in the briefing card ("✓ checked calendar · ✓ looked at tasks · ✓ pulled weather"), expanding to show tool results on click. Tool-result cards (weather, news, task list) rendered inline where useful.
|
|
||||||
|
|
||||||
**PR 4 (optional) — Sidecar storage for briefing snapshots.** If the chat-filter approach in PR 2 feels too hacky, migrate briefings out of the conversations table and into a dedicated `briefing_snapshots` table. Frontend renders them as pinned timeline cards separate from chat. Larger refactor; defer until PR 1–3 prove the approach works.
|
|
||||||
|
|
||||||
## Secondary win — tightening the main chat system prompt
|
|
||||||
|
|
||||||
The regular chat is already agentic (it uses `stream_chat_with_tools`), but its system prompt does not explicitly require the model to ground factual claims in tool results. The prompt discipline introduced for briefings — *"Never assert facts you didn't learn from a tool. If a tool returns nothing, say so honestly."* — is worth applying to the main chat system prompt in a follow-up PR. The mechanism already works; only the framing needs tightening.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- The Android Flutter client's failure to render `search_images` tool-result cards. This is a separate rendering gap in the mobile app and does not affect the server-side fix. Tracked separately.
|
|
||||||
- Re-evaluating the Ollama parallelism setting. The current non-parallel config is correct for this work.
|
|
||||||
- Replacing the background model for title/summary/observation extraction. That model's role is unrelated to briefings.
|
|
||||||
+30
-16
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## API Keys
|
## API Keys
|
||||||
|
|
||||||
API keys let external tools access your Fable data without a browser session. Each key is scoped to a single user — it can only access data that user owns or has been shared with them.
|
API keys let external tools access your Scribe data without a browser session. Each key is scoped to a single user — it can only access data that user owns or has been shared with them.
|
||||||
|
|
||||||
### Scopes
|
### Scopes
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ Admin-level operations (log access, user management) require a `write`-scoped ke
|
|||||||
|
|
||||||
### Creating a Key
|
### Creating a Key
|
||||||
|
|
||||||
1. Go to **Settings → API Keys**
|
1. Go to **Settings → MCP Access**
|
||||||
2. Enter a name (e.g. "Claude MCP", "Home Server")
|
2. Enter a name (e.g. "Claude MCP", "Home Server")
|
||||||
3. Choose scope
|
3. Choose scope
|
||||||
4. Click **Generate Key**
|
4. Click **Generate Key**
|
||||||
@@ -26,7 +26,7 @@ config (see **Scribe MCP Server** below).
|
|||||||
|
|
||||||
### Revoking a Key
|
### Revoking a Key
|
||||||
|
|
||||||
Click **Revoke** next to the key in the API Keys table and confirm. Revoked keys are deleted immediately.
|
Click **Revoke** next to the key in the keys table and confirm. Revoked keys are deleted immediately.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -41,14 +41,25 @@ client straight to the URL with a Bearer token.
|
|||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
Authenticate with an API key generated from **Settings → API Keys** (see above),
|
Authenticate with an API key generated from **Settings → MCP Access** (see above),
|
||||||
sent as `Authorization: Bearer fmcp_<key>`. A `read`-scoped key may call only the
|
sent as `Authorization: Bearer fmcp_<key>`. A `read`-scoped key may call only the
|
||||||
read tools (`get_*`, `list_*`, `search`, `enter_project`, `retrieval_telemetry`);
|
read tools (`get_*`, `list_*`, `search`, `enter_project`, `retrieval_telemetry`);
|
||||||
any write/delete tool is rejected with `403`. The allow-list is explicit rather
|
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
|
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.
|
without a read-shaped name are spelled out here. A `write`-scoped key may call everything.
|
||||||
|
|
||||||
### Claude Code (Project-scoped)
|
### Claude Code: the plugin (recommended)
|
||||||
|
|
||||||
|
The plugin connects the MCP server and adds the hooks and skills that put Scribe's records in front of Claude as it works. Install it from this repo:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add https://git.fabledsword.com/bvandeusen/FabledScribe.git
|
||||||
|
/plugin install scribe@scribe-plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
It asks for your Scribe base URL, an API key (kept in your OS keychain) and, optionally, a project id. Settings → MCP Access shows the exact command for your instance. [`plugin/README.md`](../plugin/README.md) describes each hook.
|
||||||
|
|
||||||
|
### Claude Code without the plugin
|
||||||
|
|
||||||
Add a `.mcp.json` at the project root. The server `type` is `http` and the URL is
|
Add a `.mcp.json` at the project root. The server `type` is `http` and the URL is
|
||||||
your instance's `/mcp` endpoint:
|
your instance's `/mcp` endpoint:
|
||||||
@@ -78,21 +89,24 @@ specific project at a dev instance or an admin key.
|
|||||||
|
|
||||||
### Available Tools
|
### Available Tools
|
||||||
|
|
||||||
The tool surface is large (~70 tools) and evolves with the app, so the live
|
There are well over a hundred tools and they change with the app, so the
|
||||||
registration in **`src/scribe/mcp/tools/`** is the source of truth rather than a
|
registration in **`src/scribe/mcp/tools/`** is the reference. By family:
|
||||||
table here. The tools are grouped by family:
|
|
||||||
|
|
||||||
| Family | Examples | Purpose |
|
| Family | Examples | Purpose |
|
||||||
|--------|----------|---------|
|
|--------|----------|---------|
|
||||||
| Notes | `create_note`, `get_note`, `update_note`, `delete_note`, `list_notes` | Free-form knowledge |
|
| Notes | `create_note`, `get_note`, `update_note`, `create_records` | Knowledge, and several records that cite each other in one call |
|
||||||
| Tasks | `create_task`, `update_task`, `add_task_log`, `start_planning` | Actionable work + plans |
|
| Tasks | `create_task`, `update_task`, `add_task_log` | Work, issues and spikes, and their logs |
|
||||||
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
|
| Projects / Milestones | `enter_project`, `start_planning`, `get_milestone` | Projects and their plans |
|
||||||
| Search / Recall | `search`, `get_recent`, `list_tags`, `retrieval_telemetry` | Semantic + structured recall, and the readout its thresholds are tuned from |
|
| Search / Recall | `search`, `what_might_apply`, `get_recent` | Recall by meaning, and the rules that may apply to an act |
|
||||||
| Systems | `create_system`, `list_systems`, `list_system_records` | Reusable per-project subsystems/areas |
|
| Systems | `create_system`, `list_system_records`, `map_system_to_canonical` | A project's areas |
|
||||||
| Rulebooks | `list_rules`, `create_rule`, `create_project_rule`, `relate_rules`, … | Engineering/workflow rules |
|
| Rules | `create_rule`, `create_project_rule`, `relate_rules`, `rule_history`, `list_moments` | Rules, preferences, their relations and moments |
|
||||||
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
|
| Lessons | `create_lesson`, `judge_lesson_link` | Lessons and the rules they instance |
|
||||||
|
| Snippets / Shapes | `create_snippet`, `verify_snippet`, `classify_shapes`, `list_shapes` | Prior art and the shape ledger |
|
||||||
|
| Design systems | `resolve_design_system`, `get_design_system_stylesheet` | Tokens and guidance for UI work |
|
||||||
|
| Family canon | `list_family_ideas`, `assess_family_adoption` | Ideas shared across projects |
|
||||||
|
| Processes | `list_processes`, `get_process`, `create_process` | Stored procedures |
|
||||||
|
| Retrieval | `retrieval_telemetry`, `tune_retrieval` | What was surfaced, and the thresholds |
|
||||||
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
|
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
|
||||||
| Admin | `get_app_logs` (write/admin key) | Diagnostics |
|
|
||||||
|
|
||||||
Server-level usage guidance — when to reach for each entity, the
|
Server-level usage guidance — when to reach for each entity, the
|
||||||
recall-before-acting reflex, and the rulebook conventions — is delivered to the
|
recall-before-acting reflex, and the rulebook conventions — is delivered to the
|
||||||
|
|||||||
+96
-2
@@ -52,6 +52,9 @@ endpoint at `/mcp`, not these REST routes.
|
|||||||
| POST | `/api/notes/:id/versions/:vid/pin` | Pin a version |
|
| POST | `/api/notes/:id/versions/:vid/pin` | Pin a version |
|
||||||
| GET / PUT / DELETE | `/api/notes/:id/draft` | Unsaved-edit draft (restore across page loads) |
|
| GET / PUT / DELETE | `/api/notes/:id/draft` | Unsaved-edit draft (restore across page loads) |
|
||||||
| GET | `/api/notes/graph` | Knowledge-graph nodes/edges |
|
| GET | `/api/notes/graph` | Knowledge-graph nodes/edges |
|
||||||
|
| GET | `/api/notes/duplicates` | Near-duplicate notes or tasks, grouped |
|
||||||
|
| GET | `/api/notes/due-for-verification` | Notes that carry a check, oldest verification first |
|
||||||
|
| POST | `/api/notes/:id/verify` | Record that a note's check was run `{still_true}` |
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
@@ -86,7 +89,55 @@ endpoint at `/mcp`, not these REST routes.
|
|||||||
| GET / POST | `/api/projects/:id/systems` | List / create systems |
|
| GET / POST | `/api/projects/:id/systems` | List / create systems |
|
||||||
| GET / PATCH / DELETE | `/api/projects/:id/systems/:sid` | Read / update / delete |
|
| GET / PATCH / DELETE | `/api/projects/:id/systems/:sid` | Read / update / delete |
|
||||||
| GET | `/api/projects/:id/systems/:sid/records` | Records linked to a system |
|
| GET | `/api/projects/:id/systems/:sid/records` | Records linked to a system |
|
||||||
|
| PUT | `/api/systems/:sid/canonical` | Map a system to a shared area `{canonical_id}` (`null` unmaps) |
|
||||||
|
| GET / POST | `/api/canonical-systems` | List / create shared areas |
|
||||||
|
| PATCH | `/api/canonical-systems/:id` | Update a shared area |
|
||||||
| GET | `/api/projects/:id/issues` | Project issues |
|
| GET | `/api/projects/:id/issues` | Project issues |
|
||||||
|
| PUT | `/api/projects/:id/design-system` | Point the project at a design system `{design_system_id}` (`null` clears) |
|
||||||
|
| GET / PUT | `/api/projects/:id/platforms` | The project's platforms `{platforms: {slug: "declared" \| "rejected" \| null}}` |
|
||||||
|
| PUT | `/api/projects/:id/forge` | Pin the project to one forge connection, or clear it |
|
||||||
|
| GET / POST | `/api/platforms` | Platform catalogue / add a platform |
|
||||||
|
| PATCH | `/api/platforms/:id` | Update a platform |
|
||||||
|
|
||||||
|
## Snippets and lessons
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET / POST | `/api/snippets` | List / create snippets |
|
||||||
|
| GET / PATCH / DELETE | `/api/snippets/:id` | Read / update / delete |
|
||||||
|
| POST | `/api/snippets/:id/verify` | Record a drift-check verdict `{status, detail, path}` |
|
||||||
|
| GET | `/api/snippets/duplicates` | Near-duplicate snippets, grouped into merge candidates |
|
||||||
|
| POST | `/api/snippets/:id/merge` | Merge source snippets into this one |
|
||||||
|
| POST | `/api/snippets/:id/unmerge` | Pull one source back out `{source_id}` |
|
||||||
|
| GET / POST | `/api/lessons` | List / create lessons |
|
||||||
|
| GET / PATCH / DELETE | `/api/lessons/:id` | Read / update / delete (to the trash) |
|
||||||
|
| GET | `/api/lessons/rule-candidates` | Rules a draft lesson resembles, before it is saved |
|
||||||
|
| GET | `/api/lessons/taught-by/:record_id` | Lessons drawn from one record |
|
||||||
|
| PUT | `/api/lessons/:id/rules/:rule_id` | Confirm or reject a lesson → rule link |
|
||||||
|
|
||||||
|
## Design systems
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET / POST | `/api/design-systems` | List / create |
|
||||||
|
| GET | `/api/design-systems/starter-roles` | Starter token roles for the creation form |
|
||||||
|
| GET / PATCH / DELETE | `/api/design-systems/:id` | Read / update / delete |
|
||||||
|
| GET | `/api/design-systems/:id/resolved` | The effective token set, inheritance applied |
|
||||||
|
| GET | `/api/design-systems/:id/stylesheet` | The generated stylesheet |
|
||||||
|
| GET | `/api/design-systems/:id/snippet-check` | Snippets that disagree with the system's sheet |
|
||||||
|
| GET / POST | `/api/design-systems/:id/tokens` | This system's own tokens / add one |
|
||||||
|
| PATCH / DELETE | `/api/design-tokens/:id` | Update / delete a token |
|
||||||
|
|
||||||
|
## Family canon
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/family/ideas` | Family ideas |
|
||||||
|
| GET | `/api/family/ideas/:id` | One idea with its references |
|
||||||
|
| POST | `/api/family/ideas/:id/propose` · `/promote` · `/retire` | Move an idea through its lifecycle |
|
||||||
|
| GET | `/api/family/matrix` | Projects × canon ideas, with each project's answer |
|
||||||
|
| GET | `/api/family/decisions` | Promotion and retirement decisions |
|
||||||
|
| POST | `/api/family/decisions/:id/undo` | Undo a decision |
|
||||||
|
|
||||||
## Knowledge browse
|
## Knowledge browse
|
||||||
|
|
||||||
@@ -118,6 +169,27 @@ endpoint at `/mcp`, not these REST routes.
|
|||||||
| POST | `/api/rules/:id/move` | Move a rule: `{topic_id}` makes it global, `{project_id}` makes it that project's |
|
| 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 |
|
| GET | `/api/projects/:id/rules` | A project's own rules, and the global rules tagged to its areas |
|
||||||
| POST | `/api/projects/:id/rules` | Create a project-scoped rule |
|
| POST | `/api/projects/:id/rules` | Create a project-scoped rule |
|
||||||
|
| GET | `/api/rules/:id/versions` | A rule's edit history, newest first |
|
||||||
|
| GET | `/api/rules/:id/versions/:vid` | One earlier version in full |
|
||||||
|
| POST | `/api/rules/:id/versions/:vid/restore` | Put a preference back to an earlier version (preferences only) |
|
||||||
|
| GET | `/api/rules/drift` | Preferences that have been rewritten, most recent first |
|
||||||
|
| POST | `/api/rules/:id/relations` | Relate this rule to another (`co_surfaces`, `overrides`, `elaborates`) |
|
||||||
|
| DELETE | `/api/rule-relations/:id` | Remove a relation |
|
||||||
|
| GET | `/api/rules-due-for-verification` | Rules that carry a check, oldest verification first |
|
||||||
|
| POST | `/api/rules/:id/verify` | Record that a rule's check was run `{still_true}` |
|
||||||
|
|
||||||
|
## Retrieval
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/retrieval/surfaces` | Every tunable surface with its threshold and budget |
|
||||||
|
| PUT | `/api/retrieval/surfaces/:surface` | Change one surface's setting, recorded with its reason |
|
||||||
|
| GET | `/api/retrieval/tuning-history` | Changes to retrieval on this install, newest first |
|
||||||
|
| GET | `/api/retrieval/moments` | The moments of work rules mount on, and the actions that reach each |
|
||||||
|
| POST / DELETE | `/api/retrieval/moments/mappings` | Map / unmap an action to a moment |
|
||||||
|
| GET | `/api/retrieval/moments/proposals` | Proposed rule → moment mounts awaiting a judgment |
|
||||||
|
| POST | `/api/retrieval/moments/proposals/judge` | Confirm or reject proposals |
|
||||||
|
| GET | `/api/retrieval/reply-shapes` | The default reply shapes and the moment each arrives at |
|
||||||
|
|
||||||
## Sharing
|
## Sharing
|
||||||
|
|
||||||
@@ -154,6 +226,9 @@ endpoint at `/mcp`, not these REST routes.
|
|||||||
| GET / PUT | `/api/profile` | Read / update the per-user profile |
|
| GET / PUT | `/api/profile` | Read / update the per-user profile |
|
||||||
| GET / PUT | `/api/settings` | Read / update key-value settings |
|
| GET / PUT | `/api/settings` | Read / update key-value settings |
|
||||||
| GET | `/api/settings/search` | SearXNG configuration status |
|
| GET | `/api/settings/search` | SearXNG configuration status |
|
||||||
|
| GET / POST | `/api/settings/forge-connections` | Your git forge connections / add one |
|
||||||
|
| PUT / DELETE | `/api/settings/forge-connections/:id` | Update / remove a connection |
|
||||||
|
| POST | `/api/settings/forge-connections/:id/test` | Check that the forge is reachable and accepts the token |
|
||||||
|
|
||||||
## API keys
|
## API keys
|
||||||
|
|
||||||
@@ -164,10 +239,19 @@ endpoint at `/mcp`, not these REST routes.
|
|||||||
|
|
||||||
## Plugin (Claude Code)
|
## Plugin (Claude Code)
|
||||||
|
|
||||||
|
These serve the plugin's hooks.
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/plugin/context` | SessionStart context payload (rules + active-project) |
|
| GET | `/api/plugin/context` | Session-start context: the active project and its state |
|
||||||
| GET | `/api/plugin/retrieve` | Title-first knowledge-injection candidates |
|
| GET | `/api/plugin/retrieve` | Records and rules matching the operator's prompt |
|
||||||
|
| GET | `/api/plugin/tool-rules` | Rules matching a tool call about to run |
|
||||||
|
| GET | `/api/plugin/moment-tools` | The tools whose calls can reach a moment |
|
||||||
|
| POST | `/api/plugin/moment` | The rules mounted on the moments a tool call reaches |
|
||||||
|
| POST | `/api/plugin/reply-rules` | Whether the reply ending a turn is held for one read |
|
||||||
|
| POST | `/api/plugin/rule-opened` | Records that a session opened a rule it was shown |
|
||||||
|
| GET | `/api/plugin/shape-check` | The end-of-turn question about what this turn wrote |
|
||||||
|
| GET | `/api/plugin/claim-session` · `/release-session` | Bind a session to its task claims / release them at session end |
|
||||||
| GET | `/api/plugin/processes` | Stored Processes for skill-stub sync |
|
| 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 | `/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 / 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`) |
|
||||||
@@ -197,6 +281,16 @@ endpoint at `/mcp`, not these REST routes.
|
|||||||
| GET / PUT | `/api/admin/base-url` | Get / set the public base URL |
|
| GET / PUT | `/api/admin/base-url` | Get / set the public base URL |
|
||||||
| GET / PUT | `/api/admin/db-maintenance` (+ `/health`, POST `/run`) | VACUUM schedule, health, manual run |
|
| GET / PUT | `/api/admin/db-maintenance` (+ `/health`, POST `/run`) | VACUUM schedule, health, manual run |
|
||||||
| POST / GET / DELETE | `/api/admin/invitations` (+ `/:id`) | Create / list / revoke invite links |
|
| POST / GET / DELETE | `/api/admin/invitations` (+ `/:id`) | Create / list / revoke invite links |
|
||||||
|
| GET / PUT | `/api/admin/backups` | Scheduled backups: destination, schedule and recent runs |
|
||||||
|
| POST | `/api/admin/backups/check` | Ask the destination whether it would accept a backup |
|
||||||
|
| POST | `/api/admin/backups/run` | Start a backup now |
|
||||||
|
| GET / PUT | `/api/admin/forge-webhook` | The forge push-webhook settings |
|
||||||
|
|
||||||
|
## Webhooks
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/api/webhooks/forge` | Push events from a git forge, verified with `FORGE_WEBHOOK_SECRET` **(public)** |
|
||||||
|
|
||||||
## Scribe MCP
|
## Scribe MCP
|
||||||
|
|
||||||
|
|||||||
+66
-351
@@ -1,380 +1,95 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
|
Scribe is one app container and one PostgreSQL container. There is no language model in the stack: Claude works with Scribe from outside, through the MCP endpoint and the Claude Code plugin, and people use the web UI.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
| Layer | Technology | Notes |
|
| Layer | Technology |
|
||||||
|-------|-----------|-------|
|
|-------|-----------|
|
||||||
| Frontend | Vue 3 + TypeScript + Vite + Pinia + Vue Router | SPA served from the same container as the API |
|
| Frontend | Vue 3, TypeScript, Vite, Pinia, Vue Router; Tiptap for the editor |
|
||||||
| Editor | Tiptap (ProseMirror) with custom slash-command extension | |
|
| Backend | Python 3.14, Quart (async), served by Hypercorn |
|
||||||
| Backend | Python 3.12, Quart (async ASGI) | Serves both API and built frontend static files |
|
| MCP | The `mcp` SDK's server, mounted in the same app at `/mcp` (streamable HTTP) |
|
||||||
| Database | PostgreSQL 16, SQLAlchemy 2.0 async, Alembic | asyncpg driver |
|
| Database | PostgreSQL 17 with pgvector; SQLAlchemy 2 (async, asyncpg); Alembic migrations |
|
||||||
| LLM | Ollama (local) | Any OpenAI-compatible API also works |
|
| Embeddings | fastembed running in-process (`BAAI/bge-small-en-v1.5`, 384 dimensions) |
|
||||||
| Search | SearXNG (optional, self-hosted) | Web search + image search |
|
| Plugin | Claude Code hooks (POSIX shell + awk), skills and a slash command, shipped from this repo |
|
||||||
| Push | Web Push / VAPID (pywebpush 2.x) | |
|
|
||||||
| Deployment | Docker Compose | Single-container app + separate DB + LLM service |
|
|
||||||
|
|
||||||
## High-Level Component Diagram
|
## How the pieces fit
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────┐
|
Claude Code ──── plugin hooks ──── /api/plugin/* ┐
|
||||||
│ Docker Compose │
|
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────────────────┐ ┌────────────┐ │
|
└────────── MCP tools ──────── /mcp ├── Quart app ── PostgreSQL + pgvector
|
||||||
│ │ scribe │ │ ollama │ │
|
│ │
|
||||||
│ │ ┌────────────────┐ │ │ │ │
|
Browser ──────── Vue SPA ────────── /api/* ┘ └── in-process jobs and embedder
|
||||||
│ │ │ Quart Server │ │ │ LLM API │ │
|
|
||||||
│ │ │ ┌──────────┐ │ │ │ │ │
|
|
||||||
│ │ │ │ Vue SPA │ │ │ └────────────┘ │
|
|
||||||
│ │ │ │ (static) │ │ │ ▲ │
|
|
||||||
│ │ │ └──────────┘ │ │ │ │
|
|
||||||
│ │ │ ┌──────────┐ │ │ HTTP/REST │
|
|
||||||
│ │ │ │ /api/* │──┼──┼─────────┘ │
|
|
||||||
│ │ │ └──────────┘ │ │ │
|
|
||||||
│ │ │ │ │ │ ┌────────────┐ │
|
|
||||||
│ │ │ ▼ │ │ │ PostgreSQL │ │
|
|
||||||
│ │ │ ┌──────────┐ │ │ │ 16 │ │
|
|
||||||
│ │ │ │ asyncpg │──┼──┼──▶ │ │
|
|
||||||
│ │ │ └──────────┘ │ │ └────────────┘ │
|
|
||||||
│ │ └────────────────┘ │ │
|
|
||||||
│ └──────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
- **The web UI** is a Vue single-page app, built into the image and served by Quart. Every unknown path that isn't under `/api/` falls back to `index.html`; `/api/*` always answers JSON.
|
||||||
|
- **The REST API** (`src/scribe/routes/`) backs the UI, one blueprint per resource.
|
||||||
|
- **The MCP server** (`src/scribe/mcp/`) is how Claude reads and writes records. Each module in `mcp/tools/` registers its tools, and the server's instructions and each tool's docstring are the contract an agent reads. Requests authenticate with an API key as a Bearer token.
|
||||||
|
- **The plugin** (`plugin/`) runs in the user's Claude Code. Its hooks call `/api/plugin/*` to put the right context in front of Claude at the right moment: the project at session start, matching rules and prior art before a prompt or a tool call, the rules mounted on a moment of work, and a check on the reply that ends a turn. Its skills describe the working practices. See [API Keys & MCP](api-keys-and-mcp.md) to install it.
|
||||||
|
- **Services** (`src/scribe/services/`) hold the logic both doors share. Routes and MCP tools stay thin: parse the request, check access, call a service, return a plain dict.
|
||||||
|
|
||||||
```
|
## Data model
|
||||||
scribe/
|
|
||||||
├── docker-compose.yml # Development stack
|
|
||||||
├── docker-compose.prod.yml # Production stack (Docker Swarm)
|
|
||||||
├── Dockerfile # Multi-stage build (Node → Python)
|
|
||||||
├── alembic/ # Database migrations
|
|
||||||
│ └── versions/ # Migration files (idempotent raw SQL)
|
|
||||||
├── src/scribe/
|
|
||||||
│ ├── app.py # Quart app factory + blueprint registration
|
|
||||||
│ ├── config.py # Config class (reads env vars)
|
|
||||||
│ ├── auth.py # login_required decorator, session checks
|
|
||||||
│ ├── models/ # SQLAlchemy models
|
|
||||||
│ ├── mcp/ # In-app MCP server (FastMCP, mounted at /mcp)
|
|
||||||
│ │ ├── server.py # FastMCP instance + instructions + Quart mount
|
|
||||||
│ │ └── tools/ # Tool modules (notes, tasks, projects, rulebooks, …)
|
|
||||||
│ ├── routes/ # API blueprints (one file per resource)
|
|
||||||
│ ├── services/ # Business logic (access, llm, tools, sharing, …)
|
|
||||||
│ └── static/ # Built Vue SPA (generated at Docker build time)
|
|
||||||
└── frontend/
|
|
||||||
└── src/
|
|
||||||
├── views/ # Page-level Vue components
|
|
||||||
├── components/ # Reusable UI components
|
|
||||||
├── composables/ # Vue composables (autosave, shortcuts, …)
|
|
||||||
├── stores/ # Pinia stores (auth, chat, notes, notifications, …)
|
|
||||||
└── api/ # Typed API client (client.ts)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Design Decisions
|
Most records are rows in one `notes` table, told apart by kind:
|
||||||
|
|
||||||
**Single container for frontend + API.** Quart serves the Vue.js production build as static files and exposes the REST API under `/api/`. The SPA is built by Vite during the Docker image build.
|
- **Notes**, and the specialised kinds that are notes underneath: **snippets** (recorded prior art, with a canonical code location), **lessons** (transferable insights, linked to the rules they instance) and **processes** (stored procedures, synced to Claude Code as skills).
|
||||||
|
- **Tasks**: a note with a status. A task's kind is `work`, `issue` or `spike`. Tasks carry work logs, sub-tasks, recurrence and a session claim.
|
||||||
|
|
||||||
**SPA routing via 404 handler.** `app.py` uses `@app.errorhandler(404)` (not a catch-all route) to serve static files or fall back to `index.html`. API routes (`/api/*`) always return JSON 404. This avoids a catch-all `/<path:path>` route intercepting API GETs.
|
Around them:
|
||||||
|
|
||||||
**Unified note/task model.** A task is just a note with task attributes enabled. `status IS NOT NULL` means it's a task. "Convert to task" sets `status='todo'`; "convert to note" clears `status`, `priority`, `due_date`. No separate table, no cascade complexity.
|
- **Projects** group work and carry a goal. **Milestones** are plans: a design body plus ordered step tasks.
|
||||||
|
- **Systems** are a project's named areas; records are tagged with them, and each maps to a canonical system shared across projects.
|
||||||
|
- **Rulebooks → topics → rules.** A rule in a rulebook is global; a rule on a project applies there only. Rules have a trigger (`when_to_apply`), versions, relations to other rules, and can be mounted on **moments** of work (`work.change`, `reply.report`, ...), which plugin actions reach through moment mappings.
|
||||||
|
- **Family canon**: ideas shared across projects on the same platform, and each project's answer to them (adopted, variant, owed).
|
||||||
|
- **Design systems**: tokens and guidance, with inheritance; the stylesheet is generated from them.
|
||||||
|
- **The shape ledger**: code shapes extracted from bound repos and their classification against snippets.
|
||||||
|
- **Embeddings** for notes (chunked), rules, milestones and systems, in pgvector columns.
|
||||||
|
- **Telemetry**: retrieval logs, note and rule usage, and the tuning history of retrieval thresholds.
|
||||||
|
- **Users, groups, shares and API keys.** Access is decided in the app (`services/access.py`): owners, direct shares and group shares, with read or write permission.
|
||||||
|
|
||||||
**First-class tag column.** Tags live in a `tags ARRAY[text]` column and are explicitly set by the client — not auto-extracted from body text. Hierarchical tags (`project/webapp`) supported via SQL `unnest + LIKE` prefix matching.
|
Deleted records go to the trash first and can be restored until retention purges them.
|
||||||
|
|
||||||
**Background generation architecture.** LLM streaming runs in a detached `asyncio.Task` that writes into an in-memory `GenerationBuffer`. SSE clients tail the buffer and can reconnect mid-stream without data loss. Buffer has a `cancel_event` for user-initiated stop. Completed buffers are cleaned up after 60s grace period. Periodic DB flushes every 5s preserve partial content. Both chat and AI Assist use this architecture.
|
## Retrieval
|
||||||
|
|
||||||
**SSE over WebSockets for LLM streaming.** SSE clients connect via `GET /api/chat/conversations/:id/generation/stream` with `Last-Event-ID` reconnection support. Frontend uses `fetch()` + `ReadableStream`.
|
Search and the plugin's context injection run on the same pipeline (`services/retrieval_pipeline.py`): embed the query, rank stored chunks by cosine similarity in pgvector, keep what clears the surface's threshold, and log what was shown. Each surface (MCP search, auto-inject, the pre-tool rule arm, the write-path prior-art arm, ...) is a spec over those stages with its own thresholds and budget. The thresholds are settings an admin can tune, and every change is recorded with its reason. When the embedder is unavailable, search falls back to keyword matching.
|
||||||
|
|
||||||
**Context building is server-side.** Backend fetches URL content and searches notes. Frontend sends the message text + optional context note IDs. `build_context()` returns `(messages, context_meta)`; metadata includes auto-found note IDs/titles sent to frontend via a `context` SSE event before streaming begins.
|
The embedding model downloads into `FASTEMBED_CACHE_DIR` (default `/data/fastembed-cache`) on first use, so the `/data` volume keeps it across restarts.
|
||||||
|
|
||||||
**No blocking long-running operations.** Any slow operation (model pulls, LLM calls, URL fetching) must never block app startup or freeze the UI. Backend uses SSE streaming for incremental responses. Model pulls stream NDJSON progress to the frontend.
|
## Background work
|
||||||
|
|
||||||
**SSRF protection.** `services/llm.py` blocks requests to loopback, private, link-local, reserved, and multicast addresses before fetching. `follow_redirects=False` prevents redirect-based bypasses.
|
All in-process; there is no separate worker container.
|
||||||
|
|
||||||
**Session cookie security.** `HttpOnly` and `SameSite=Lax` always set. `Secure` flag controlled by `SECURE_COOKIES` env var.
|
- At startup the app records any shipped default that moved in this release, starts its schedulers, and only then backfills missing embeddings, so the backfill never competes with startup for the database.
|
||||||
|
- Recurring tasks spawn every 15 minutes. Daily jobs: version auto-pinning (03:00 UTC), trash purge (03:30 UTC), targeted `VACUUM ANALYZE` (04:00 UTC by default). Scheduled backups check hourly whether one is due ([Backups](backups.md)).
|
||||||
|
- Log, notification and auth-token retention run on their own loops.
|
||||||
|
- A diagnostics heartbeat writes to `/data/diagnostics`.
|
||||||
|
|
||||||
**Rate limiting.** In-memory sliding-window rate limiter (`rate_limit.py`) applied to auth endpoints: login (10/60s), register (5/300s), forgot-password (5/300s), reset-password (10/60s). Keys are per-IP. `TRUST_PROXY_HEADERS` env var enables `X-Forwarded-For` / `X-Real-IP` when behind a reverse proxy.
|
Every database wait on startup has a deadline. Migrations wait for the database to accept connections (up to five minutes) before they run, so the app starts cleanly whatever order the containers come up in.
|
||||||
|
|
||||||
**Idempotent migrations.** All Alembic migrations use raw SQL with `CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and `DO $$ BEGIN CREATE TYPE ... EXCEPTION WHEN duplicate_object` to allow safe re-runs.
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
### Users
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|--------|------|-------|
|
|
||||||
| `id` | int PK | |
|
|
||||||
| `username` | text UNIQUE NOT NULL | |
|
|
||||||
| `email` | text nullable | |
|
|
||||||
| `password_hash` | text nullable | NULL for OAuth-only accounts |
|
|
||||||
| `oauth_sub` | text UNIQUE nullable | OIDC subject identifier |
|
|
||||||
| `role` | text NOT NULL DEFAULT 'user' | First user auto-assigned 'admin' |
|
|
||||||
| `session_version` | int NOT NULL DEFAULT 1 | Bumped on password change to evict sessions |
|
|
||||||
| `created_at` | timestamptz | |
|
|
||||||
|
|
||||||
### Notes (unified — includes tasks)
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
|--------|------|-------|
|
|
||||||
| `id` | int PK | |
|
|
||||||
| `title` | text | |
|
|
||||||
| `body` | text | Markdown |
|
|
||||||
| `tags` | ARRAY[text] | GIN indexed; explicitly set by client |
|
|
||||||
| `parent_id` | int FK self nullable | Sub-tasks / sub-notes |
|
|
||||||
| `user_id` | int FK users nullable | CASCADE |
|
|
||||||
| `project_id` | int FK projects nullable | |
|
|
||||||
| `milestone_id` | int FK milestones nullable | |
|
|
||||||
| `status` | text nullable | `todo`/`in_progress`/`done` — non-null = task |
|
|
||||||
| `priority` | text nullable | `none`/`low`/`medium`/`high` |
|
|
||||||
| `due_date` | date nullable | |
|
|
||||||
| `created_at`, `updated_at` | timestamptz | |
|
|
||||||
|
|
||||||
Indexes: GIN on `tags`, B-tree on `status`, B-tree on `title`.
|
|
||||||
|
|
||||||
### Settings
|
|
||||||
|
|
||||||
Composite PK `(user_id, key)`. Per-user key-value store. CRUD via `services/settings.py`. Used for: `default_model`, `assistant_name`, `journal_config` (JSON: locations, temp_unit, prep schedule), `user_timezone`, `voice_*`, etc.
|
|
||||||
|
|
||||||
### Conversations / Messages
|
|
||||||
|
|
||||||
`conversations`: `id`, `title`, `model`, `user_id`, `conversation_type` (`chat`/`journal`/`mcp`), `day_date` (YYYY-MM-DD for journal conversations), `rag_project_id` (nullable int — RAG scope: NULL=orphan-only, -1=all, positive=project), `created_at`, `updated_at`.
|
|
||||||
`messages`: `id`, `conversation_id` FK CASCADE, `role` (`user`/`assistant`), `content`, `status` (`done`/`generating`), `created_at`.
|
|
||||||
|
|
||||||
Title auto-generated by LLM on first exchange, re-generated every 10th message.
|
|
||||||
|
|
||||||
### Projects / Milestones
|
|
||||||
|
|
||||||
`projects`: `id`, `user_id`, `title`, `description`, `goal`, `status` (`active`/`completed`/`archived`), `color`, `auto_summary` (nullable text — LLM-generated summary for `search_projects` scoring), `summary_updated_at` (nullable timestamptz), timestamps.
|
|
||||||
`milestones`: `id`, `user_id`, `project_id` FK CASCADE, `title`, `description`, `status`, `order_index`, timestamps.
|
|
||||||
|
|
||||||
### Sharing & Access
|
|
||||||
|
|
||||||
`project_shares`, `note_shares`: each has `shared_with_user_id` OR `shared_with_group_id` (exclusive), `permission` (`viewer`/`editor`/`admin`), `invited_by`.
|
|
||||||
|
|
||||||
`groups`, `group_memberships`: platform-wide groups with `member`/`owner` roles.
|
|
||||||
|
|
||||||
Permission resolution is centralised in `services/access.py`. `get_project_permission(uid, project_id)` checks ownership → direct share → group-based share → note→project inheritance, returning the highest applicable permission.
|
|
||||||
|
|
||||||
### Journal-Related Tables
|
|
||||||
|
|
||||||
`weather_cache`: per-user, per-`location_key` cache. Columns: `user_id`, `location_key` (`home`/`work`/etc.), `location_label`, `forecast_json` (Open-Meteo response), `previous_json` (last forecast, used to detect changes), `fetched_at`. Lat/lon are *not* stored on the cache row — they live in the user's `journal_config.locations.{home|work}` setting and are used at refresh time.
|
|
||||||
|
|
||||||
### API Keys
|
|
||||||
|
|
||||||
`api_keys`: `id`, `user_id` FK CASCADE, `prefix` (first 8 chars, displayed in UI), `key_hash` (SHA-256 of full key — full key never stored), `name`, `scope` (`read`/`write`), `created_at`, `last_used_at`.
|
|
||||||
|
|
||||||
### App Logs
|
|
||||||
|
|
||||||
`category` (`audit`/`usage`/`error`), `user_id` FK nullable (SET NULL on delete), `username` (denormalised), `action`, `endpoint`, `method`, `status_code`, `duration_ms`, `error_type`, `error_message`, `traceback`, `details` JSONB.
|
|
||||||
|
|
||||||
## Detailed File Reference
|
|
||||||
|
|
||||||
### Backend (`src/scribe/`)
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|---------------|
|
|
||||||
| `app.py` | Quart app factory; SPA via 404 handler; JSON 404/500 for API; request logging; security headers in `after_request` |
|
|
||||||
| `auth.py` | `login_required`, `admin_required`, `get_current_user_id` — shared `_check_auth()` helper; accepts session cookie or `Authorization: Bearer <key>` |
|
|
||||||
| `config.py` | All config from env vars + Docker secret file support (`_read_secret`); `SECURE_COOKIES`, `TRUST_PROXY_HEADERS`, `OLLAMA_NUM_CTX`; `oidc_enabled()`, `searxng_enabled()` classmethods |
|
|
||||||
| `rate_limit.py` | In-memory sliding-window rate limiter (`asyncio.Lock` + `defaultdict`); `is_rate_limited(key, max, window)` |
|
|
||||||
| `models/note.py` | Unified Note model (notes + tasks) |
|
|
||||||
| `models/user.py` | `id`, `username`, `email`, `password_hash` (nullable — NULL for OAuth-only), `oauth_sub` (unique nullable), `role`, `session_version` |
|
|
||||||
| `models/conversation.py` | `Conversation` + `Message` models |
|
|
||||||
| `models/app_log.py` | `AppLog` with `category`, denormalised `username`, `details` JSONB |
|
|
||||||
| `models/api_key.py` | `ApiKey`: `id`, `user_id`, `prefix`, `key_hash` (SHA-256), `name`, `scope` (`read`/`write`), `created_at`, `last_used_at` |
|
|
||||||
| `models/event.py` | Internal events store (`Event` model: `id`, `user_id`, `title`, `description`, `start_dt`, `end_dt`, `all_day`, `location`, `caldav_uid` nullable, `color`, timestamps) |
|
|
||||||
| `routes/api.py` | `/api` blueprint; `GET /api/health` (public) |
|
|
||||||
| `routes/auth.py` | Register, login, logout, me, password/email change, password reset, invite registration, OAuth login+callback; rate limiting; `LOCAL_AUTH_ENABLED` guards |
|
|
||||||
| `routes/admin.py` | Backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only) |
|
|
||||||
| `routes/chat.py` | Conversations CRUD; SSE generation stream; model pull/delete/list/warm |
|
|
||||||
| `routes/notes.py` | Notes CRUD + wikilinks + backlinks + assist + link suggestions + version history + drafts |
|
|
||||||
| `routes/tasks.py` | Tasks CRUD; `POST` accepts `project` name string (resolved to `project_id`) |
|
|
||||||
| `routes/task_logs.py` | Task work log CRUD (`GET/POST/DELETE /api/tasks/:id/logs`) |
|
|
||||||
| `routes/projects.py` | Projects CRUD + summary endpoint |
|
|
||||||
| `routes/milestones.py` | Milestones CRUD under `/api/projects/:id/milestones` |
|
|
||||||
| `routes/settings.py` | Per-user settings key-value (`GET/PUT /api/settings/:key`) |
|
|
||||||
| `routes/journal.py` | Journal config CRUD; today/day/days conversation accessors; weather endpoints (cached/current/refresh/geocode); moments CRUD; trigger-prep |
|
|
||||||
| `routes/groups.py` | Group CRUD + membership management (admin) |
|
|
||||||
| `routes/shares.py` | Share project/note with user or group; revoke; list incoming shares |
|
|
||||||
| `routes/in_app_notifications.py` | In-app notification list + mark-read + count |
|
|
||||||
| `routes/push.py` | Web Push subscription subscribe/unsubscribe; VAPID public key |
|
|
||||||
| `routes/users.py` | User profile; admin user list + delete |
|
|
||||||
| `routes/images.py` | Serve cached images at `/api/images/<id>` |
|
|
||||||
| `routes/export.py` | `GET /api/export` — personal Markdown ZIP or JSON array download |
|
|
||||||
| `routes/api_keys.py` | API key CRUD (`GET/POST/DELETE /api/api-keys`) |
|
|
||||||
| `mcp/server.py` | Mounts the in-app FastMCP server at `/mcp` (streamable HTTP, Bearer auth) |
|
|
||||||
| `routes/quick_capture.py` | `POST /api/quick-capture` — single-shot natural language item creation |
|
|
||||||
| `routes/search.py` | `GET /api/search` — semantic + keyword hybrid search |
|
|
||||||
| `services/auth.py` | `create_user`, `authenticate`, user lookups, password reset tokens, invitation tokens |
|
|
||||||
| `services/oauth.py` | OIDC discovery (cached), PKCE auth URL, code exchange, `find_or_create_oauth_user` |
|
|
||||||
| `services/api_keys.py` | `generate_key()`, `create_api_key()`, `list_api_keys()`, `revoke_api_key()`, `lookup_key()` (SHA-256 hash lookup) |
|
|
||||||
| `services/llm.py` | `build_context()`, RAG injection, history summarisation, `stream_chat_with_tools()`, URL fetching, SSRF guard |
|
|
||||||
| `services/generation_task.py` | `run_generation()` — full chat pipeline: intent routing, tool loop, SSE fan-out, push notification; `run_assist_generation()` |
|
|
||||||
| ~~`services/intent.py`~~ | Removed — intent routing eliminated; the main model handles all tool routing directly |
|
|
||||||
| `services/tools/` | LLM tool package — decorator-based registry (`_registry.py`), shared helpers (`_helpers.py`), and one module per domain: `notes.py` (create/update/delete/search/list/read), `tasks.py` (list/log_work), `entities.py` (save_person/save_place/lists), `projects.py`, `calendar.py`, `web.py`, `rag.py`, `profile.py`, `rss.py`, `weather.py`, `utility.py` (calculate). `execute_tool()` and `get_tools_for_user()` are the public API. |
|
|
||||||
| `services/projects.py` | Project CRUD + `generate_project_summary()` (Ollama, fire-and-forget) + `backfill_project_summaries()` (startup) |
|
|
||||||
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes(orphan_only=False)` (pgvector cosine similarity) |
|
|
||||||
| `services/generation_buffer.py` | In-memory SSE event buffer; `cancel_event`; 60s cleanup; supports both chat (int keys) and assist (string keys) |
|
|
||||||
| `services/notes.py` | Note CRUD, wikilink resolution, backlink queries, tag management |
|
|
||||||
| `services/note_versions.py` | Version snapshot on save; restore-from-version; diff metadata |
|
|
||||||
| `services/note_drafts.py` | Per-user per-note draft persistence (AI Assist pending state) |
|
|
||||||
| `services/settings.py` | `get_setting()`, `set_setting()`, `get_all_settings()` — key-value per user |
|
|
||||||
| `services/tag_suggestions.py` | `/api/notes/suggest-tags` — LLM-generated tag suggestions for note body |
|
|
||||||
| `services/access.py` | Permission resolution for all shared resources |
|
|
||||||
| `services/sharing.py` | Create/revoke shares; list shares; `get_shared_with_me()` |
|
|
||||||
| `services/groups.py` | Group CRUD; membership management |
|
|
||||||
| `services/notifications.py` | Create/read/mark-read in-app notifications |
|
|
||||||
| `services/task_logs.py` | Append/list/delete work log entries on tasks |
|
|
||||||
| `services/journal_prep.py` | Deterministic data gather (tasks/events/weather/projects/recent moments/open threads) → LLM prose opener; persisted as the first assistant message of today's journal Conversation |
|
|
||||||
| `services/journal_pipeline.py` | System-prompt builder for journal conversations; calls `build_profile_context()` so the LLM sees the user's profile + learned summary |
|
|
||||||
| `services/journal_scheduler.py` | APScheduler `BackgroundScheduler`; per-user prep job; live-reschedule via `update_user_schedule()`; catch-up logic for missed runs |
|
|
||||||
| `services/journal_search.py`, `services/moments.py` | Moment recording + search across journal history |
|
|
||||||
| `services/user_profile.py` | `build_profile_context()` consolidates profile + observations + learned_summary into a system-prompt block |
|
|
||||||
| `services/research.py` | SearXNG research pipeline: sub-queries → parallel fetch → outline → section synthesis → executive summary → index note with linked section notes |
|
|
||||||
| `services/events.py` | Internal events CRUD: `list_events`, `create_event`, `update_event`, `delete_event`, `get_event`; source of truth for all event LLM tools |
|
|
||||||
| `routes/events.py` | `/api/events` — event CRUD routes |
|
|
||||||
| `services/caldav.py` | Optional CalDAV sync — user-configured external server; syncs to/from internal store via `caldav_uid` FK; `is_caldav_configured()` guards tool activation |
|
|
||||||
| `services/calendar_sync.py` | Dead code — Radicale sync service; was trialled and removed |
|
|
||||||
| `services/images.py` | `fetch_and_store_image()` — SHA-256 dedup, content-type validation, 5 MB cap |
|
|
||||||
| `services/backup.py` | `export_full_backup()`, `export_user_backup()`, `restore_full_backup()` (version 2 with ID maps) |
|
|
||||||
| `services/push.py` | VAPID key auto-generation; `send_push_notification()` fire-and-forget; 410 Gone cleanup |
|
|
||||||
| `services/logging.py` | `log_audit`, `log_usage`, `log_error`, `start_log_retention_loop` (hourly cleanup) |
|
|
||||||
| `services/email.py` | SMTP email sending for password reset; reads `SMTP_*` env vars |
|
|
||||||
| `services/weather.py` | Nominatim geocoding + Open-Meteo forecast + per-user DB cache |
|
|
||||||
| `services/rss.py` | feedparser fetch; per-feed DB cache; prune-to-100 items |
|
|
||||||
| `services/assist.py` | `build_assist_messages()` — section/whole-doc modes; project context injection |
|
|
||||||
|
|
||||||
### Frontend (`frontend/src/`)
|
|
||||||
|
|
||||||
| File | Responsibility |
|
|
||||||
|------|---------------|
|
|
||||||
| `App.vue` | App shell (`100dvh` flex column); global keyboard shortcuts (`g`+key nav, `n/t/c/e///?`); starts status polling + loads settings on mount |
|
|
||||||
| `api/client.ts` | `ApiError`, `apiGet/Post/Put/Patch/Delete`, `apiSSEStream` (fetch + ReadableStream), auto 401→login redirect |
|
|
||||||
| `stores/auth.ts` | User, `isAuthenticated`, `isAdmin`, `oauthEnabled`, `localAuthEnabled`, login/register/logout/checkAuth |
|
|
||||||
| `stores/chat.ts` | Conversation CRUD; `sendMessage()` SSE streaming; `reconnectIfGenerating()`; message queue (localStorage); `streamingStatus` |
|
|
||||||
| `stores/notes.ts` | CRUD + tag filter; `resolveTitle`; `convertToTask/Note`; `fetchBacklinks`; `fetchAllTags` |
|
|
||||||
| `stores/tasks.ts` | CRUD + status/priority filter; `patchStatus` |
|
|
||||||
| `stores/settings.ts` | `assistantName`, `defaultModel`, `installedModels`; `pullModel()`, `deleteModel()` |
|
|
||||||
| `stores/push.ts` | `isSupported`, `permission`, `isSubscribed`, `subscribe/unsubscribe` |
|
|
||||||
| `stores/notifications.ts` | `count`, `items`, `fetchCount`, `fetchAll`, `markRead`, `markAll` |
|
|
||||||
| `views/HomeView.vue` | Chat-first dashboard; 6 task sections (Overdue → Other); 8 recent notes; inline streaming response |
|
|
||||||
| `views/ChatView.vue` | `/chat` — SSE streaming; context sidebar (Suggested/In Context); note picker; scope chip (RAG project scope pill above input); message queue; bulk delete |
|
|
||||||
| `views/CalendarView.vue` | `/calendar` — FullCalendar v6 month/week/day views; click to create event; click event to open EventSlideOver |
|
|
||||||
| `components/EventSlideOver.vue` | Reusable slide-over for event create/edit/delete; used in ToolCallCard, HomeView, CalendarView |
|
|
||||||
| `views/WorkspaceView.vue` | `/workspace/:id` — 3-panel (tasks/chat/notes); SSE tool-call watcher; conv persisted to localStorage |
|
|
||||||
| `views/GraphView.vue` | `/graph` — D3 force-directed; tag/note/project-hub nodes; physics panel; peek panel |
|
|
||||||
| `views/ProjectView.vue` | Kanban grouped by milestone; advance buttons; milestone management |
|
|
||||||
| `views/SettingsView.vue` | Tabbed settings (11 tabs); tab state in localStorage |
|
|
||||||
| `views/NoteEditorView.vue` | Tiptap editor; 2-column layout; AI assist panel; diff view; version history; autosave |
|
|
||||||
| `views/TaskEditorView.vue` | Tiptap editor; 2-column layout; AI assist panel; task log section; sub-tasks |
|
|
||||||
| `components/ToolCallCard.vue` | Tool call results; `requires_confirmation` inline confirm/deny; direct POST on "Create anyway" |
|
|
||||||
| `components/ChatMessage.vue` | Message bubble; markdown rendering; tool call cards; "Save as Note" |
|
|
||||||
| `components/TagInput.vue` | Chip-based tag input; Enter/comma to confirm; autocomplete from `/api/notes/tags` |
|
|
||||||
| `components/TiptapEditor.vue` | Tiptap wrapper; markdown↔HTML round-trip; selection change emit; WikilinkDecoration; TagDecoration |
|
|
||||||
| `components/ShareDialog.vue` | `<Teleport>` modal; user tab + group tab; current shares list |
|
|
||||||
| `components/WorkspaceTaskPanel.vue` | Milestone-grouped task list; detail slide-over |
|
|
||||||
| `components/WorkspaceNoteEditor.vue` | List ↔ TipTap editor with autosave |
|
|
||||||
| `composables/useAssist.ts` | AI assist: section parsing, SSE streaming, accept/reject, LCS diff, persistent draft |
|
|
||||||
| `composables/useAutoSave.ts` | Interval-based autosave (5 min) with dirty/saving guards |
|
|
||||||
| `composables/useEditorGuards.ts` | Ctrl+S, `beforeunload` warning, route-leave confirm |
|
|
||||||
| `composables/useTagSuggestions.ts` | `/api/notes/suggest-tags` call + suggestion state |
|
|
||||||
| `composables/useListKeyboardNavigation.ts` | j/k/Enter list navigation; focus-in-input guard |
|
|
||||||
| `extensions/WikilinkDecoration.ts` | ProseMirror decoration plugin highlighting `[[wikilinks]]` |
|
|
||||||
| `extensions/TagDecoration.ts` | ProseMirror decoration plugin highlighting `#tags` |
|
|
||||||
| `extensions/WikilinkSuggestion.ts` | `@tiptap/suggestion` extension for `[[` autocomplete |
|
|
||||||
|
|
||||||
## Key Services
|
|
||||||
|
|
||||||
| Service | Responsibility |
|
|
||||||
|---------|---------------|
|
|
||||||
| `services/access.py` | Permission resolution for all shared resources |
|
|
||||||
| `services/llm.py` | `build_context()`, RAG injection, history summarisation |
|
|
||||||
| `services/generation_task.py` | SSE streaming, tool-call loop, GenerationBuffer management |
|
|
||||||
| `services/tools/` | LLM tool implementations (38 tools across 11 modules); decorator-based registry |
|
|
||||||
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes()` |
|
|
||||||
| `services/journal_prep.py` | Data gather → LLM prose opener; persisted into today's journal conversation |
|
|
||||||
| `services/journal_scheduler.py` | APScheduler integration, per-user prep job, catch-up for missed runs |
|
|
||||||
| `services/backup.py` | Full and per-user backup export/restore (version 2 format) |
|
|
||||||
| `services/weather.py` | Nominatim geocoding + Open-Meteo forecast fetch + DB cache |
|
|
||||||
| `services/rss.py` | feedparser-based fetch, per-feed DB cache, prune-to-100 |
|
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
**Session cookies** — `HttpOnly`, `SameSite=Lax`, optionally `Secure` (`SECURE_COOKIES` env var). Session includes `session_version`; mismatch with DB value (after password change) results in 401 and session clear.
|
- **Sessions**: `HttpOnly`, `SameSite=Lax` cookies, `Secure` when `SECURE_COOKIES=true`. Changing a password bumps the user's session version and signs out their other sessions.
|
||||||
|
- **API keys**: `Authorization: Bearer fmcp_...`, stored hashed. A `read` key may only call read endpoints and read MCP tools.
|
||||||
|
- **OIDC** single sign-on with PKCE, optionally replacing local logins ([SSO / OAuth](sso-oauth.md)).
|
||||||
|
- **Rate limits** on register, login and forgot-password, per client IP (`TRUST_PROXY_HEADERS` reads the IP from the proxy's headers).
|
||||||
|
|
||||||
**Bearer token / API keys** — `Authorization: Bearer <key>` accepted by `_check_auth()` as an alternative to session cookies. The raw key is SHA-256 hashed and looked up via `services/api_keys.py`. Keys with `scope=read` are rejected on non-safe methods (`POST`/`PATCH`/`DELETE`). Used by Fable MCP and any external API consumers.
|
Credentials Scribe stores for outside services (forge tokens, the backup destination's S3 secret) are sealed under a key derived from `SECRET_KEY`.
|
||||||
|
|
||||||
**Local auth + OIDC/OAuth (PKCE)** — `services/oauth.py` handles discovery and `find_or_create_oauth_user`. On OAuth login: checks existing `oauth_sub` → matching email → creates new user.
|
## Repository layout
|
||||||
|
|
||||||
See [sso-oauth.md](sso-oauth.md) for provider-specific setup instructions.
|
```
|
||||||
|
├── alembic/versions/ database migrations
|
||||||
## LLM Pipeline Internals
|
├── src/scribe/
|
||||||
|
│ ├── app.py app factory, blueprints, startup and shutdown
|
||||||
### Tool Routing
|
│ ├── config.py environment configuration
|
||||||
|
│ ├── models/ SQLAlchemy models
|
||||||
No separate intent router — the main model handles all tool routing directly via Ollama's structured tool-calling output. The model receives the full tool schema list and decides whether to call a tool or respond conversationally. Extended reasoning (`think=True`) is always on for qwen3-class models: content-based gating was tried but exposed tool-call template fragility on short tool-intent prompts.
|
│ ├── routes/ REST blueprints
|
||||||
|
│ ├── mcp/ MCP server and its tool modules
|
||||||
### Tool Loop
|
│ ├── services/ shared logic
|
||||||
|
│ └── restore.py `python -m scribe.restore` for a fresh instance
|
||||||
Multi-round tool loop (max 5 rounds). All implementations in `services/tools/` (decorator-based registry); `execute_tool(user_id, tool_name, arguments, conv_id=None, workspace_project_id=None)` is the dispatcher. `conv_id` and `workspace_project_id` are threaded in from `run_generation()` so tools like `set_rag_scope` can write to the current conversation.
|
├── frontend/src/ views, components, stores, composables, api client
|
||||||
|
├── plugin/ Claude Code hooks, skills and commands
|
||||||
**Unified `create_note` tool** — creates both notes and tasks. Setting `status` (e.g. `"todo"`) creates a task; omitting it creates a knowledge note. All task fields (due_date, priority, milestone, parent_task, recurrence_rule) are available on the single tool.
|
├── scripts/ CI checks and the plugin version minter
|
||||||
|
└── tests/ unit tests, plus `-m integration` against real Postgres
|
||||||
**Duplicate protection on `create_note`:**
|
```
|
||||||
1. Exact title match (case-insensitive) → hard block, redirect to `update_note`
|
|
||||||
2. Fuzzy title match (SequenceMatcher ≥ 82%; punctuation stripped before candidate search) → hard block
|
|
||||||
3. Semantic content similarity (threshold 0.90, body ≥ 80 chars) → soft block with `requires_confirmation: true`
|
|
||||||
|
|
||||||
**Project resolution** (`_helpers.resolve_project`): 4-step lookup — (1) exact DB match, (2) `query in title` substring, (3) `title in query` reverse substring, (4) SequenceMatcher ≥ 0.55.
|
|
||||||
|
|
||||||
### Context Window and Summarisation
|
|
||||||
|
|
||||||
`OLLAMA_NUM_CTX` (default 16384) controls the context window for all generation calls. Intent classification always uses `num_ctx=4096` to reduce VRAM pressure.
|
|
||||||
|
|
||||||
History summarisation threshold: 30 messages. Keeps 8 recent messages. Summary max 400 tokens.
|
|
||||||
|
|
||||||
### Web Research Pipeline
|
|
||||||
|
|
||||||
`services/research.py` implements a full autonomous research pipeline:
|
|
||||||
1. Intent model generates 5 focused sub-queries
|
|
||||||
2. All 5 SearXNG queries run in parallel (200ms stagger to avoid rate limiter)
|
|
||||||
3. Up to 15 unique URLs fetched in parallel
|
|
||||||
4. LLM generates an outline (2–8 sections with title + focus)
|
|
||||||
5. Each section synthesised in parallel from relevant sources
|
|
||||||
6. Executive summary generated from all section content
|
|
||||||
7. Index note created with executive summary + links to section notes; section notes linked back via `parent_id`
|
|
||||||
8. Falls back to single-note synthesis if outline generation fails
|
|
||||||
|
|
||||||
SearXNG tip: add the app server IP to `botdetection.ip_lists.pass_ip` in SearXNG `settings.yml` to bypass the rate limiter for trusted backend requests.
|
|
||||||
|
|
||||||
### Image Cache
|
|
||||||
|
|
||||||
`search_images` tool fetches images server-side via SearXNG, stores them on disk (SHA-256 dedup, content-type validation, 5 MB cap), and serves from `/api/images/<id>`. The user's browser never contacts the original image host.
|
|
||||||
|
|
||||||
Config: `IMAGE_CACHE_DIR` (default `/data/images`), `IMAGE_MAX_BYTES` (default 5 MB).
|
|
||||||
|
|
||||||
## RAG Pipeline
|
|
||||||
|
|
||||||
1. `semantic_search_notes()` — cosine similarity via pgvector, threshold configurable per call; accepts `orphan_only` and `project_id` scope flags.
|
|
||||||
2. Notes ≥ 0.60 similarity auto-injected into system prompt (up to 3, 800 chars each).
|
|
||||||
3. Notes 0.45–0.60 surfaced in chat sidebar as "Suggested" (user clicks to include).
|
|
||||||
4. Explicitly included notes delivered full-body.
|
|
||||||
5. `excluded_note_ids` prevents the current note from being injected as its own context.
|
|
||||||
|
|
||||||
### RAG Scope (three-value system)
|
|
||||||
|
|
||||||
`conversations.rag_project_id` controls which notes are eligible for retrieval:
|
|
||||||
|
|
||||||
| Value | Behaviour |
|
|
||||||
|-------|-----------|
|
|
||||||
| `NULL` (default) | Orphan notes only — notes with `project_id IS NULL` |
|
|
||||||
| `-1` | All notes — opt-in to global search |
|
|
||||||
| Positive int | That project's notes only |
|
|
||||||
|
|
||||||
`build_context()` derives `orphan_only` + `effective_project_id` from this value before calling both the semantic and keyword search paths.
|
|
||||||
|
|
||||||
**Scope tools** — Two LLM tools let the model discover and switch scope mid-conversation:
|
|
||||||
- `search_projects` — SequenceMatcher scoring over title + description + `auto_summary`; returns top 5 matching projects.
|
|
||||||
- `set_rag_scope` — persists the new `rag_project_id` to DB immediately; blocked in workspace view; causes the SSE `done` event to include `new_rag_scope` + `new_rag_scope_label` so the frontend chip updates reactively.
|
|
||||||
|
|
||||||
**Project summaries** — `generate_project_summary()` calls Ollama (fire-and-forget) and stores the result in `projects.auto_summary`. Triggered on project update and note saves (debounced 1h). `backfill_project_summaries()` runs at startup.
|
|
||||||
|
|
||||||
Embedding model: `nomic-embed-text` via Ollama. Backfill runs 30s after startup (background task).
|
|
||||||
|
|||||||
+69
-99
@@ -1,108 +1,100 @@
|
|||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
Configuration is via environment variables. The `docker-compose.yml` file sets defaults for local development; override them in a `.env` file (gitignored).
|
Scribe reads its configuration from environment variables. Most settings people change day to day (registration, SMTP, retrieval thresholds, backups, forge connections) live in **Settings** in the web UI instead.
|
||||||
|
|
||||||
## Environment Variables
|
Any variable marked *secret* can also be read from a file: set `<NAME>_FILE` to the path, for example a Docker secret at `/run/secrets/scribe_key`.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
### Core
|
### Core
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/scribe` | PostgreSQL async connection string |
|
| `DATABASE_URL` *(secret)* | `postgresql+asyncpg://scribe:scribe@localhost:5432/scribe` | PostgreSQL connection string, using the `asyncpg` driver |
|
||||||
| `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` *(secret)* | `dev-secret-change-me` | Signs sessions and seals stored credentials. Set it before first use and keep it (see below) |
|
||||||
| `SECRET_KEY_FILE` | — | Path to a Docker secret file containing the key (alternative to `SECRET_KEY`) |
|
| `BASE_URL` | `http://localhost:5000` | Public URL, used in email links and OIDC redirects |
|
||||||
| `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
|
| `SECURE_COOKIES` | `false` | `true` when Scribe is served over HTTPS |
|
||||||
| `SECURE_COOKIES` | `false` | Set `true` when running behind TLS |
|
| `TRUST_PROXY_HEADERS` | `false` | `true` behind a reverse proxy, to read the client IP from `X-Forwarded-For` / `X-Real-IP` |
|
||||||
| `BASE_URL` | — | Public URL (e.g. `https://notes.example.com`) — required for OIDC redirect URIs and email links |
|
| `LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING` or `ERROR` |
|
||||||
|
| `LOG_RETENTION_DAYS` | `90` | Days of app logs to keep |
|
||||||
|
| `FASTEMBED_CACHE_DIR` | `/data/fastembed-cache` | Where the embedding model is stored after its first download |
|
||||||
|
|
||||||
### LLM / Ollama
|
### Sign-in
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `OLLAMA_URL` | `http://ollama:11434` | Ollama API base URL |
|
| `LOCAL_AUTH_ENABLED` | `true` | `false` turns off username/password login, leaving SSO only |
|
||||||
| `OLLAMA_MODEL` | `llama3.2` | Default LLM model (used as fallback; per-user setting overrides this) |
|
| `OIDC_ISSUER` | — | OIDC issuer URL |
|
||||||
| `EMBEDDING_MODEL` | `nomic-embed-text` | Model used for semantic search / RAG embeddings |
|
|
||||||
| `OLLAMA_NUM_CTX` | `16384` | Context window size passed to Ollama for all generation calls |
|
|
||||||
|
|
||||||
### Authentication / OIDC
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `LOCAL_AUTH_ENABLED` | `true` | Set `false` to disable local username/password login (SSO-only mode) |
|
|
||||||
| `OIDC_ISSUER` | — | OIDC issuer URL (e.g. `https://auth.example.com/application/o/fabled/`) |
|
|
||||||
| `OIDC_CLIENT_ID` | — | OIDC client ID |
|
| `OIDC_CLIENT_ID` | — | OIDC client ID |
|
||||||
| `OIDC_CLIENT_SECRET` | — | OIDC client secret |
|
| `OIDC_CLIENT_SECRET` *(secret)* | — | OIDC client secret |
|
||||||
| `OIDC_CLIENT_SECRET_FILE` | — | Docker secret file alternative to `OIDC_CLIENT_SECRET` |
|
| `OIDC_SCOPES` | `openid profile email` | Scopes to request |
|
||||||
| `OIDC_SCOPES` | `openid profile email` | Space-separated OIDC scopes to request |
|
|
||||||
|
|
||||||
See [sso-oauth.md](sso-oauth.md) for provider-specific setup.
|
SSO turns on when the issuer, client ID and secret are all set. See [SSO / OAuth](sso-oauth.md).
|
||||||
|
|
||||||
### Security / Proxy
|
### Email
|
||||||
|
|
||||||
|
Admins can set SMTP in **Settings**; these are the defaults until they do.
|
||||||
|
|
||||||
|
| Variable | Default |
|
||||||
|
|----------|---------|
|
||||||
|
| `SMTP_HOST` | — |
|
||||||
|
| `SMTP_PORT` | `587` |
|
||||||
|
| `SMTP_USERNAME` | — |
|
||||||
|
| `SMTP_PASSWORD` *(secret)* | — |
|
||||||
|
| `SMTP_FROM_ADDRESS` | — |
|
||||||
|
| `SMTP_FROM_NAME` | `Fabled Scribe` |
|
||||||
|
| `SMTP_USE_TLS` | `true` |
|
||||||
|
|
||||||
|
### Integrations
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `TRUST_PROXY_HEADERS` | `false` | Set `true` when behind a trusted reverse proxy to read real IP from `X-Forwarded-For` / `X-Real-IP` |
|
| `PLUGIN_MARKETPLACE_URL` | this repo's git URL | The git URL shown in the plugin install command under Settings → MCP Access. An admin can override it in Settings |
|
||||||
|
| `FORGE_KIND`, `FORGE_BASE_URL`, `FORGE_TOKEN` *(secret)* | — | A git forge connection for admins' projects, so a token can live in a Docker secret. Each user can add their own under Settings → Integrations; a stored connection for the same host wins |
|
||||||
|
| `FORGE_WEBHOOK_SECRET` *(secret)* | — | Verifies push webhooks from the forge |
|
||||||
|
| `SEARXNG_URL` | — | A SearXNG instance, listed as the web-search backend under Settings → Integrations |
|
||||||
|
|
||||||
### Web Search / Images
|
## Running it
|
||||||
|
|
||||||
| Variable | Default | Description |
|
### Quick start
|
||||||
|----------|---------|-------------|
|
|
||||||
| `SEARXNG_URL` | — | SearXNG base URL for web search and image search tools |
|
|
||||||
| `IMAGE_CACHE_DIR` | `/data/images` | Directory for cached search images |
|
|
||||||
| `IMAGE_MAX_BYTES` | `5242880` (5 MB) | Maximum size for cached images |
|
|
||||||
|
|
||||||
### Data / Logging
|
[`docker-compose.quickstart.yml`](../docker-compose.quickstart.yml) runs the published image with its own database:
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `LOG_RETENTION_DAYS` | `90` | Days to keep app logs before automatic pruning |
|
|
||||||
| `DATA_DIR` | `/data` | Root directory for persistent data (VAPID keys, backups) |
|
|
||||||
|
|
||||||
## Docker Compose Setup
|
|
||||||
|
|
||||||
### Development (`docker-compose.yml`)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Copy the example env file
|
SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_hex(32))') \
|
||||||
cp .env.example .env
|
docker compose -f docker-compose.quickstart.yml up -d
|
||||||
# Edit .env to set a real SECRET_KEY
|
```
|
||||||
|
|
||||||
# Start the stack
|
Open `http://localhost:5000`. The first account registered becomes the admin; after that, registration stays closed until an admin opens it in Settings or sends an invite.
|
||||||
|
|
||||||
|
### From source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # then set SECRET_KEY
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
|
|
||||||
# App available at http://localhost:5000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The first user to register becomes admin. Registration auto-closes after that (re-enable from Settings → Users).
|
### Docker Swarm
|
||||||
|
|
||||||
### Production (`docker-compose.prod.yml`)
|
[`docker-compose.prod.yml`](../docker-compose.prod.yml) is a stack file: the app and database on a private overlay network, with `SECURE_COOKIES` and `TRUST_PROXY_HEADERS` on. Provide `DB_PASSWORD` and `SECRET_KEY` in the environment:
|
||||||
|
|
||||||
The production compose file adds:
|
|
||||||
- Docker Secrets for `SECRET_KEY_FILE` and `DATABASE_URL_FILE`
|
|
||||||
- Network isolation (internal bridge for DB + Ollama; only the app is externally accessible)
|
|
||||||
- Health checks and resource limits
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create Docker secrets
|
DB_PASSWORD=... SECRET_KEY=... docker stack deploy -c docker-compose.prod.yml scribe
|
||||||
echo "$(python3 -c 'import secrets; print(secrets.token_hex(32))')" | docker secret create fabled_secret_key -
|
|
||||||
echo "postgresql+asyncpg://fabled:strongpassword@db/scribe" | docker secret create fabled_db_url -
|
|
||||||
|
|
||||||
# Deploy
|
|
||||||
docker stack deploy -c docker-compose.prod.yml fabled
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Production Deployment
|
The shipped files leave out healthchecks and restart policies on purpose. The app waits for its database on startup, and the orchestrator's defaults restart it if it ever exits.
|
||||||
|
|
||||||
### Reverse Proxy (Required)
|
### Volumes
|
||||||
|
|
||||||
Fabled Assistant does **not** handle SSL/TLS. Run it behind a reverse proxy:
|
- **Database** (`pgdata`): everything Scribe stores.
|
||||||
|
- **App data** (`/data`): the embedding model cache and diagnostics. Losing it costs a re-download, nothing else.
|
||||||
|
|
||||||
- **Nginx**, **Traefik**, or **Caddy** in front of the app container
|
Back up the database with **Settings → Data → Scheduled backups** ([Backups](backups.md)).
|
||||||
- Terminate TLS at the proxy; forward to port 5000
|
|
||||||
- **Do not expose port 5000 directly to the internet**
|
## Behind a reverse proxy
|
||||||
- Rate-limit auth endpoints: ≤ 5 req/min per IP on `/api/auth/login` and `/api/auth/register`
|
|
||||||
|
Scribe doesn't terminate TLS. Put it behind Nginx, Traefik or Caddy, forward to port 5000, and keep port 5000 off the internet. Set `SECURE_COOKIES=true`, `TRUST_PROXY_HEADERS=true` and `BASE_URL`.
|
||||||
|
|
||||||
Example Nginx location block:
|
|
||||||
```nginx
|
```nginx
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:5000;
|
proxy_pass http://127.0.0.1:5000;
|
||||||
@@ -111,40 +103,18 @@ location / {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
# Required for SSE streaming
|
# MCP responses can stream.
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_read_timeout 600s;
|
proxy_read_timeout 600s;
|
||||||
proxy_send_timeout 600s;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
If using `TRUST_PROXY_HEADERS=true`, ensure your proxy strips any client-supplied `X-Forwarded-For` headers before adding its own.
|
With `TRUST_PROXY_HEADERS=true`, make sure the proxy replaces any `X-Forwarded-For` the client sends.
|
||||||
|
|
||||||
### Security Checklist
|
## Security checklist
|
||||||
|
|
||||||
- **Strong `SECRET_KEY`** — generate with:
|
- **Set a strong `SECRET_KEY`, and keep it.** Forge tokens and the backup destination's S3 secret are sealed under a key derived from it, so a copy of the database alone can't read them. If the key changes (say, a restore onto an instance with a different key), Settings marks those credentials and asks for them again; nothing else breaks. The app logs a warning at startup while the default key is in use, and refuses to start with it when `SECURE_COOKIES=true`.
|
||||||
```bash
|
- **`SECURE_COOKIES=true`** whenever Scribe is served over HTTPS.
|
||||||
python3 -c "import secrets; print(secrets.token_hex(32))"
|
- **Registration** is open only for the first account. Invite people, or open it briefly from Settings.
|
||||||
```
|
- **Signing everyone else out**: changing a password ends that user's other sessions; Settings → Account has a button for it too.
|
||||||
Or use Docker Secrets via `SECRET_KEY_FILE`.
|
- **API keys** are per user, shown once, and can be read-only. Revoke any you no longer use under Settings → MCP Access.
|
||||||
- **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.
|
|
||||||
- **Session invalidation** — changing or resetting a password bumps `session_version`, evicting all other active sessions. Button also available in Settings → Account.
|
|
||||||
- **Keep Ollama on an internal network** — both compose files keep Ollama off the host network. Never expose the Ollama port publicly.
|
|
||||||
- **Default SECRET_KEY warning** — the app logs a `WARNING` on startup if the default dev key is in use.
|
|
||||||
|
|
||||||
## Model Management
|
|
||||||
|
|
||||||
Models are managed through Settings → General → Model Management in the web UI. You can:
|
|
||||||
- Pull new models by name (streams progress via SSE)
|
|
||||||
- View installed models with size and loaded/unloaded state
|
|
||||||
- Delete unused models
|
|
||||||
|
|
||||||
The app auto-warms user-preferred models on startup (only models already installed — never auto-pulls). The embedding model (`nomic-embed-text`) is auto-pulled on startup if missing.
|
|
||||||
|
|
||||||
Recommended models for 2× 8 GB GPU:
|
|
||||||
- **`qwen3:8b`** — strong reasoning + tools, fits in 8 GB, supports thinking mode
|
|
||||||
- **`qwen2.5:7b`** — fast, tool-capable, smaller context
|
|
||||||
- **`llama3.1:8b`** — reliable baseline, widely tested with tools
|
|
||||||
|
|||||||
+8
-215
@@ -354,220 +354,13 @@ If using Tailwind, extend the theme with these tokens rather than relying on def
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Last updated: April 25, 2026. Iterate as the family of apps grows.*
|
# Scribe
|
||||||
|
|
||||||
---
|
The design system Scribe renders with is stored in Scribe itself: **Scribe**, which inherits **FabledSword**, edited at `/design-systems`. `frontend/src/assets/theme.css` is generated from it, and CI rejects a `var()` the sheet doesn't define. Where this page and the stored system differ, the stored system is right. Scribe applies the baseline above with these choices:
|
||||||
|
|
||||||
# Scribe-specific decisions in progress
|
- **Accent footprint.** Dusty violet marks Scribe's own moments: the wordmark, active navigation, the editor's cursor and selection, tags and wikilinks, in-progress status, focus rings, and the call to action that starts something new (a first note, say). Operating the software uses the action colours: Moss to save or confirm, Bronze for alternatives, Oxblood with an icon to delete, Pewter ghost for tertiary actions.
|
||||||
|
- **Light and dark.** Both are supported. Light mode is warm parchment (`#F5F1E8` page, `#FBF8F0` cards, `#14171A` text) rather than white, with a cool wash on code blocks so syntax colours stay readable. The accent, action and semantic colours are the same in both.
|
||||||
> This section tracks decisions made while adapting the FabledSword baseline above for Scribe specifically. Items here are *in progress* — once they feel solid, they get folded into the main body of the document (either as Scribe-specific extensions in the per-app section, or as updates to the universal rules where Scribe's needs reveal a gap in the baseline).
|
- **Status and priority.** Status: todo Pewter, in progress the accent, done Moss, cancelled Ash, paused Warning. Priority runs cool to warm: low Info, medium Warning, high Error. Overdue uses Error. Each has a low-alpha `-bg` companion.
|
||||||
|
- **Type.** Inter for UI and body, Fraunces at 18px and up (display, H1, H2), JetBrains Mono for code. Two weights only, 400 and 500. Line height 1.5 for body, 1.3 for headings, 1.7 on reading surfaces such as a note.
|
||||||
*Iteration started: 2026-04-26. Foundation pass shipped 2026-04-27 in `7a9a8b7` (palette, fonts, light mode, action tokens, hardcoded indigo cleanup, warm-gold deprecation). Surface phase shipped 2026-04-27 across `93a3beb` → `3c1ec40` (Lucide migration, Hybrid-rule button reclassification per surface, long-form line-height, two-weights-only). The system is now applied end-to-end; this section will fold into the main body once the result has had time to settle in real use.*
|
- **Icons.** Lucide, at 16px inline and 24px for navigation and primary actions; no emoji in the interface.
|
||||||
|
- **Borders are structural.** Cards, dialogs, code blocks, focus rings and major dividers get borders; list rows, chips and empty states are separated by surface tint and spacing instead.
|
||||||
## Decisions made so far
|
|
||||||
|
|
||||||
### Accent footprint — Hybrid rule (not Strict)
|
|
||||||
|
|
||||||
The doc baseline says the per-app accent only appears on wordmark, active nav, cursor, and text selection — never on action buttons. Scribe currently uses indigo on essentially every interactive surface (CTAs, scrollbars, glows, borders, focus rings). Hard-cutting to the doc baseline would lose too much identity in one swing.
|
|
||||||
|
|
||||||
**Hybrid rule:** the accent reserves a slightly larger footprint than the doc baseline, but still much smaller than today.
|
|
||||||
|
|
||||||
- **Accent (dusty violet) lives on:** wordmark; active nav; cursor and text selection in editor surfaces; tags/pills/wikilinks; in-progress task badge; focus rings; **brand-moment CTAs** — chat Send, "Create note" empty-state CTA, journal Send, "Start journaling" empty-state.
|
|
||||||
- **Moss (sage-green primary) lives on:** Save / Submit / Confirm in forms and modals; generic affirmative actions where the button just means "do this thing" with no brand pretense.
|
|
||||||
- **Bronze (secondary):** Cancel-but-not-destructive, alternative paths.
|
|
||||||
- **Oxblood (destructive):** Delete / Remove (paired with an icon).
|
|
||||||
- **Pewter ghost:** tertiary actions, "later", "skip", "see also".
|
|
||||||
|
|
||||||
**Rule of thumb:** if the user is engaging with a *Scribe-feature moment* (sending a chat, opening a fresh note, jumping into the journal), accent. If they're just *operating the software* (saving an edit, confirming a dialog), Moss.
|
|
||||||
|
|
||||||
### Light mode — warm parchment, matched aesthetic
|
|
||||||
|
|
||||||
The doc is dark-only. Scribe today supports both light and dark, and we keep both. The light mode is derived to *match* the dark mode aesthetic rather than defaulting to system white-and-ink.
|
|
||||||
|
|
||||||
- Page background: in the `#F5F1E8` warm cream family (specific values TBD)
|
|
||||||
- Cards: near-white but slightly tinted
|
|
||||||
- Text: deep ink `#14171A` (mirroring Obsidian)
|
|
||||||
- Accent: same dusty violet `#5B4A8A` (works on both themes)
|
|
||||||
|
|
||||||
The metaphor stays consistent across themes: ink on aged paper (light) ↔ parchment text on graphite (dark). Light mode is *not* the system standard look.
|
|
||||||
|
|
||||||
**Known downside:** warm parchment backgrounds can fight with embedded color content. Mitigation: code blocks get a slight cool wash in light mode specifically, to keep syntax highlighting readable.
|
|
||||||
|
|
||||||
### Status and priority palette — extend the doc's semantic set
|
|
||||||
|
|
||||||
The doc's semantic colors (Success / Warning / Error / Info / Destructive) are leaner than what Scribe needs for task management. Rather than running a parallel palette, Scribe extends the doc by mapping its status/priority tokens onto doc primitives where they fit and defining new app-level tokens for the rest.
|
|
||||||
|
|
||||||
**Status (task lifecycle):**
|
|
||||||
|
|
||||||
| Token | Color | Source | Logic |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `status-todo` | Pewter `#3F4651` | doc | Neutral, "not started yet" |
|
|
||||||
| `status-in-progress` | dusty violet `#5B4A8A` | accent | Active = brand moment per Hybrid |
|
|
||||||
| `status-done` | Moss `#4A5D3F` | doc Success | Affirmative completion |
|
|
||||||
| `status-cancelled` | Ash `#9C9A92` | doc | Faded, "let go" |
|
|
||||||
| `status-paused` | Warning `#8B6F1E` | doc | Stalled, needs attention — replaces the old warm gold treatment |
|
|
||||||
|
|
||||||
**Priority (loudness scale):**
|
|
||||||
|
|
||||||
| Token | Color | Source | Logic |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `priority-low` | Info `#3D5A6E` | doc | Cool, FYI — quietest end of the spectrum |
|
|
||||||
| `priority-medium` | Warning `#8B6F1E` | doc | Golden, mid-attention |
|
|
||||||
| `priority-high` | Error `#C04A1F` | doc | Terracotta, urgent |
|
|
||||||
| `priority-none` | Vellum/Ash | doc | No signal |
|
|
||||||
|
|
||||||
The priority row reads as a clean cool→warm gradient (slate blue → golden brown → terracotta), which matches the semantic loudness — coherence the current ad-hoc palette doesn't have.
|
|
||||||
|
|
||||||
**Other functional tokens:**
|
|
||||||
|
|
||||||
| Token | Color | Logic |
|
|
||||||
|---|---|---|
|
|
||||||
| `wikilink` | dusty violet | Editorial brand moment per Hybrid |
|
|
||||||
| `overdue` | Error `#C04A1F` | Same as priority-high — overdue IS a priority signal |
|
|
||||||
| `toast-success` | Moss | doc semantic |
|
|
||||||
| `toast-error` | Error | doc semantic |
|
|
||||||
| `toast-info` | Info | doc semantic |
|
|
||||||
| `tag-bg` / `tag-text` | accent at 15% / accent | Per doc pill recipe |
|
|
||||||
|
|
||||||
Each token gets a `*-bg` companion at low alpha (matching the existing pattern in `theme.css`).
|
|
||||||
|
|
||||||
**Removed:** the warm gold accent (`--color-accent-warm: #b8860b`). Its two jobs split:
|
|
||||||
|
|
||||||
- Dates and timestamps (knowledge cards, event details, chat) → use `text-secondary` instead. Dates are metadata, not a brand surface; muted is the correct register.
|
|
||||||
- Paused project status → use the new `status-paused` (Warning `#8B6F1E`) row above. Same golden-brown family, semantically aligned.
|
|
||||||
|
|
||||||
### Typography — adopt the doc's stack and scale
|
|
||||||
|
|
||||||
Adopt the doc's type stack and scale verbatim, with one deferred verification (long-form line-height in practice).
|
|
||||||
|
|
||||||
- **Body font: Inter.** Replaces Scribe's current system-stack body font. Doc-defined; no Scribe-specific divergence.
|
|
||||||
- **Type scale:** as in the doc table — Display 40 / H1 32 / H2 24 / H3 18 / Body 15 / Body small 13 / Label 12 / Code 13 / Tiny 11.
|
|
||||||
- **Two weights only:** 400 regular, 500 medium. No 600/700 (reads heavy in dark mode and against the muted palette).
|
|
||||||
- **Family rules:** Fraunces at 18px+ only (Display, H1, H2). H3 and below = Inter. Code = JetBrains Mono.
|
|
||||||
- **Line height:** 1.5 body, 1.3 headings, **1.7 for long-form reading surfaces** (notes, journal entries).
|
|
||||||
- **Sentence case for everything**, except the Tiny micro-label style which gets uppercase + 0.08em letter-spacing.
|
|
||||||
|
|
||||||
Mechanical rollout — value swaps in `theme.css` plus loading Inter and JetBrains Mono from Google Fonts (Fraunces is already loaded).
|
|
||||||
|
|
||||||
### Light mode — concrete palette
|
|
||||||
|
|
||||||
Fills in the warm-parchment direction picked earlier. Treat these as starting values; tune in practice.
|
|
||||||
|
|
||||||
| Token | Hex | Role |
|
|
||||||
|---|---|---|
|
|
||||||
| Page bg | `#F5F1E8` | Warm cream — the "paper" |
|
|
||||||
| Card bg | `#FBF8F0` | Near-white, slightly warm — raised surfaces |
|
|
||||||
| Inset bg (inputs, code) | `#EFEAE0` | Slightly darker than page, "inset" feeling |
|
|
||||||
| Text primary | `#14171A` | Deep ink — Obsidian inverted |
|
|
||||||
| Text secondary | `#5A5852` | Warm mid-grey |
|
|
||||||
| Text muted | `#9A9890` | Warm light grey for hints/metadata |
|
|
||||||
| Border default | `#D9D6CE` | Warm light pewter, hairline weight |
|
|
||||||
|
|
||||||
**Code-block exception:** in light mode specifically, code blocks use a slight cool wash (e.g. `#EBEDF0`) instead of the warm inset bg, so syntax highlighting reads cleanly. This is the mitigation for the "warm bg fights colored content" downside.
|
|
||||||
|
|
||||||
The accent (`#5B4A8A` dusty violet), Moss, Bronze, Oxblood, and the semantic color set are **identical across themes** — only the surface and text palettes flip.
|
|
||||||
|
|
||||||
### Chat-bubble codification — keep the Illuminated Transcript pattern
|
|
||||||
|
|
||||||
The existing chat-bubble pattern (informally called "Illuminated Transcript") gets written into the design system as a documented chat component. Other apps in the family that add a chat surface inherit the pattern; Scribe's existing implementation continues to work with only color shifts.
|
|
||||||
|
|
||||||
**User bubble (whisper):**
|
|
||||||
- Background: transparent
|
|
||||||
- Border: 0.5px Pewter (was: indigo-tinted)
|
|
||||||
- Text color: secondary (Vellum dark / `#5A5852` light)
|
|
||||||
- Right-aligned, rounded except bottom-right (subtle "from-me" tail)
|
|
||||||
|
|
||||||
**Assistant bubble (lit):**
|
|
||||||
- Background: card surface (Iron dark / `#FBF8F0` light)
|
|
||||||
- Border: none on top/right/bottom; **2px solid accent (dusty violet) on left edge only**
|
|
||||||
- Box-shadow: accent-tinted glow + standard depth shadow (formula: `0 4px 28px rgba(<accent>, 0.14), 0 2px 8px rgba(0,0,0,0.4)` in dark; lower alphas in light)
|
|
||||||
- Text color: primary (Parchment dark / Obsidian-inverted light)
|
|
||||||
- Left-aligned, rounded except bottom-left
|
|
||||||
|
|
||||||
The 2px-accent left edge is the "illumination" — like an illuminated capital in a manuscript. The shadow is the lift. Together they make the assistant bubble read as the *primary* voice, while the user bubble is the *margin note*.
|
|
||||||
|
|
||||||
**Inline tool-call cards (`ToolCallCard`)** rendered inside an assistant bubble do NOT get their own border (per the border philosophy — the bubble already contains them). They use a slight surface tint to differentiate.
|
|
||||||
|
|
||||||
### Iconography — adopt Lucide, enforce a scale
|
|
||||||
|
|
||||||
Scribe currently hand-inlines SVG paths in 16+ Vue files, with 5 different stroke weights and 8+ different sizes. The visual style is already outline + rounded caps + `currentColor` stroke (matches the doc's intent), but there's no shared source and no scale discipline.
|
|
||||||
|
|
||||||
**Migration policy:**
|
|
||||||
|
|
||||||
1. **Install `lucide-vue-next`** as the icon source. Replace hand-inlined SVGs with imported components. Single source of truth.
|
|
||||||
2. **Strict size scale: 16px and 24px only.** Today's mix of 12/13/14/15/17/18/20 collapses to those two. 16 for inline-with-text and small affordances; 24 for nav and primary actions.
|
|
||||||
3. **Stroke weight per the doc: 1.5 at 24px, 1 at 16px.** Lighter than the current default of 2 — reads more refined, matches the muted palette philosophy. Overrides Lucide's default.
|
|
||||||
4. **Outline by default; filled only for active/selected state.** Introduces a new affordance Scribe doesn't currently use — bookmark/pin/star icons can switch outline → filled to indicate active state. Reserve filled style strictly for this.
|
|
||||||
5. **No emoji in chrome.** Replace the 3 files' emoji usage in UI labels/buttons/badges/empty states with Lucide equivalents. Emoji remain fine in *user content* (note bodies, chat messages the user typed).
|
|
||||||
|
|
||||||
Work cost: ~30-60 individual icon swaps across the 16 files. Mechanical; doesn't require redesign of any component.
|
|
||||||
|
|
||||||
### Voice and tone — adopt principles, defer formal audit
|
|
||||||
|
|
||||||
The doc's voice register applies to Scribe (understated mythic — plain for functional UI, flavored for empty/error/loading states). No formal sweep of every UI string yet.
|
|
||||||
|
|
||||||
**Approach:** apply the voice opportunistically as components are touched in the polish pass — when redesigning a settings tab, an empty state, or an error toast, rewrite the copy at the same time using the doc's register and examples table as the guide. A standalone audit pass is deferred unless drift becomes visible.
|
|
||||||
|
|
||||||
### Border philosophy — structural, not decorative
|
|
||||||
|
|
||||||
The doc treats borders as *structural* (Pewter neutral hairlines that say "boundary"), not decorative (Scribe today uses indigo-tinted borders that say "branded edge"). That principle suggests removing borders in places where surface tint and spacing already communicate separation.
|
|
||||||
|
|
||||||
**Borders to remove:**
|
|
||||||
|
|
||||||
- List rows (NotesListView, TasksListView, conversation history) — surface contrast + spacing should separate rows; current border reads as "boxed-in"
|
|
||||||
- Inline `ToolCallCard` inside chat bubbles — the bubble is already a container; an extra border feels like double-wrapping
|
|
||||||
- Filter chips and search-bar pills with a background tint — background does the work
|
|
||||||
- Empty-state callouts with dashed/bordered "nothing here yet" boxes — tinted background reads cleaner
|
|
||||||
|
|
||||||
**Borders to keep (genuinely structural):**
|
|
||||||
|
|
||||||
- Standalone card containers (Notes viewer, Task viewer, the new daily prep card)
|
|
||||||
- Modal / dialog edges
|
|
||||||
- Code blocks (separates content type, not just space)
|
|
||||||
- Focus rings (accessibility)
|
|
||||||
- Major section dividers within a panel
|
|
||||||
|
|
||||||
Border weight is not load-bearing for Scribe — happy to use the doc's 0.5px hairline default; the *placement* discipline matters more than the weight.
|
|
||||||
|
|
||||||
## Open threads (next iterations)
|
|
||||||
|
|
||||||
### Foundation pass — shipped 2026-04-27 (`7a9a8b7`)
|
|
||||||
|
|
||||||
Mechanical token + font + light-mode rewrite of `frontend/src/assets/theme.css`, plus a sweep of hardcoded indigo and `--color-accent-warm` references across ~14 component files. Action tokens (`--color-action-primary` Moss, `--color-action-secondary` Bronze, `--color-action-destructive` Oxblood, `--color-action-ghost-border` Pewter) are defined but not yet applied — buttons still flow through `--color-primary` and read as dusty-violet gradients in the meantime, by design. Spec lives at `docs/superpowers/specs/2026-04-27-design-system-polish-foundation-design.md` (gitignored, local-only).
|
|
||||||
|
|
||||||
### Surface phase — shipped 2026-04-27 (`93a3beb` → `3c1ec40`)
|
|
||||||
|
|
||||||
Bundled as Hybrid (option C from the brainstorm): Lucide cross-cutting first, then surface-by-surface for the judgment work. Spec lives at `docs/superpowers/specs/2026-04-27-design-system-polish-surface-design.md` (gitignored, local-only). Seven PRs landed on `dev`:
|
|
||||||
|
|
||||||
| PR | Commit | Surface | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | `93a3beb` | Lucide cross-cutting | 60 hand-inlined SVGs across 15 files → `lucide-vue-next`. Every chrome icon at 16 or 24. Emoji-as-icons (`✕`, `✓`, `🎤`, `📎`, `↻`, `↑`, `×`, `☀`/`☾`) swept across the chrome. AppLogo wordmark and the GraphView D3 mount kept as the legitimate exceptions. |
|
|
||||||
| 2 | `3d916d7` | Journal | Buttons audited; `↻` weather refresh → Lucide `RotateCcw`; assistant bubble line-height bumped; redundant `.journal-title` font-family dropped; dead `.news-section` CSS removed. |
|
|
||||||
| 3 | `4192a64` | Chat | `.message-content` long-form 1.7 line-height on assistant bubbles; ToolCallCard outer border removed (bubble already contains it; error state moved to a left-edge accent); bulk-delete recolored to Oxblood with Trash2 icon; `.bulk-link` accent → muted text. |
|
|
||||||
| 4 | `efb3534` | Knowledge cluster | `.prose` line-height bumped to 1.7 globally so all reading surfaces inherit. Save → Moss; Delete → Oxblood + Trash2; Edit / Advance → Moss; Convert / Share → Bronze. |
|
|
||||||
| 5 | `ff498ce` | Project + Workspace | Project save panel → Moss; milestone confirm/cancel → Moss/Bronze; modal-btn-danger and per-row delete affordances → Oxblood; "Open Workspace" stays accent (project-surface brand moment). |
|
|
||||||
| 6 | `541e2ed` | Settings | Densest button surface — every `.btn-save`, `.btn-primary`, `.btn-secondary`, `.btn-danger`, `.btn-danger-outline`, `.btn-toggle-open` reclassified per Hybrid; missing `.btn-danger` style block added (was unstyled before). |
|
|
||||||
| 7 | `3c1ec40` | Edge surfaces | Calendar `New Event` → Moss; EventSlideOver Save/Cancel/Delete reclassified; HomeView hero CTA stays accent (brand moment); two-weights-only sweep across Header/Home/Calendar/Graph. |
|
|
||||||
|
|
||||||
**Cross-cutting changes folded in as the work touched files:**
|
|
||||||
|
|
||||||
- Long-form 1.7 line-height on `.prose` (PR 4) — applies to Note viewer, Task viewer, chat assistant bubbles, anywhere markdown renders into a reading surface.
|
|
||||||
- Two-weights-only (400 + 500) — every `font-weight: 600` and `700` snapped to `500` across all surface PRs.
|
|
||||||
- Hardcoded `--color-danger` in destructive button contexts → `--color-action-destructive` (Oxblood). `--color-danger` (Error terracotta) preserved for validation/error messages, per the doc's distinction between Error and Destructive.
|
|
||||||
- Adjacent `×` / `✕` / unicode-arrow emoji swept opportunistically as files were touched (PRs 5, 7).
|
|
||||||
|
|
||||||
### Out of scope — deferred indefinitely
|
|
||||||
|
|
||||||
Items deliberately not addressed in this round; revisit when a real need surfaces:
|
|
||||||
|
|
||||||
- Lucide stroke-weight overrides (doc spec: 1.5 at 24, 1 at 16; current: Lucide default 2). Touched components if they read too heavy in practice.
|
|
||||||
- Filled-as-active icon state — no current affordance uses it; introduce when bookmark/pin/star toggles are added.
|
|
||||||
- Type-scale / spacing-scale CSS variables — components keep literal values.
|
|
||||||
- Token rename to `--fs-*` namespace — Scribe is the only FabledSword app sharing this codebase.
|
|
||||||
- FabledSword lockup placement — waiting on the actual heraldic mark to be drawn.
|
|
||||||
- Standalone voice/tone audit across every UI string — opportunistic-only; full sweep deferred unless drift becomes visible.
|
|
||||||
- A handful of editor utility buttons (`.btn-suggest-tags`, `.btn-link-all`, AI assist generate/proofread/accept/reject set, etc.) — currently ghost-styled and visually compliant; revisited only if they read off in practice.
|
|
||||||
|
|
||||||
### Open threads
|
|
||||||
|
|
||||||
*New threads will accumulate here as gaps surface in real use.*
|
|
||||||
|
|||||||
+48
-140
@@ -1,179 +1,87 @@
|
|||||||
# Development
|
# Development
|
||||||
|
|
||||||
## Workflow
|
## Running it
|
||||||
|
|
||||||
All development is Docker-based. Do not install Python or Node dependencies locally.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start the full stack (app + PostgreSQL + Ollama)
|
docker compose up --build # app + PostgreSQL at http://localhost:5000
|
||||||
docker compose up --build
|
docker compose up --build app # rebuild after a change
|
||||||
|
docker compose down -v # stop and wipe the database
|
||||||
|
```
|
||||||
|
|
||||||
# Rebuild after backend changes (frontend changes require rebuild too)
|
The image builds the frontend, so a frontend change needs a rebuild too. For quicker frontend work, run Vite against the running app; it proxies `/api` to port 5000:
|
||||||
docker compose up --build app
|
|
||||||
|
|
||||||
# Reset everything (wipes database)
|
```bash
|
||||||
docker compose down -v && docker compose up --build
|
cd frontend && npm ci && npm run dev # http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
# Run checks (lint, format, typecheck, tests)
|
## Checks
|
||||||
make check
|
|
||||||
|
|
||||||
# Individual checks
|
CI runs these on every push; locally they need `uv sync --extra dev` and `cd frontend && npm ci`.
|
||||||
|
|
||||||
|
```bash
|
||||||
make lint # ruff check src/
|
make lint # ruff check src/
|
||||||
make fmt # ruff format src/
|
|
||||||
make typecheck # vue-tsc --noEmit
|
make typecheck # vue-tsc --noEmit
|
||||||
make test # pytest tests/
|
make test # pytest tests/
|
||||||
|
make check # all three
|
||||||
```
|
```
|
||||||
|
|
||||||
## Frontend Hot Reload
|
Unit tests run without a database. Tests marked `integration` need a real PostgreSQL and run in CI's integration job, against a schema built by the real migrations.
|
||||||
|
|
||||||
The Docker setup does not include Vite's hot-reload dev server. After frontend changes, rebuild the image. For faster iteration during active frontend work, you can run Vite locally:
|
## Database migrations
|
||||||
|
|
||||||
|
The container runs `alembic upgrade head` on every start, after waiting for the database to accept connections.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
docker compose exec app alembic revision -m "short_description"
|
||||||
npm install
|
|
||||||
npm run dev # Vite dev server at http://localhost:5173
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Point the Vite dev server at the backend by setting `VITE_API_BASE_URL=http://localhost:5000` (or configure `vite.config.ts` proxy).
|
- Number revisions sequentially (`0123_...`). Revision ids must fit in 32 characters, the width of `alembic_version.version_num`.
|
||||||
|
- Write both `upgrade()` and `downgrade()`.
|
||||||
|
- Adding a value to a `CHECK`-constrained column means dropping and re-adding the constraint in the same migration.
|
||||||
|
- Keep migrations to schema changes. Filling a derived table from a large one belongs in a background backfill after startup, where its cost doesn't hold up the boot.
|
||||||
|
- A migration that depends on application code (a key derivation, a serialiser) copies what it needs rather than importing it, so a later edit can't change what an old migration does.
|
||||||
|
|
||||||
## Database Migrations
|
## The plugin
|
||||||
|
|
||||||
Alembic migrations run automatically on container startup (`alembic upgrade head` in the `CMD`).
|
`plugin/` ships straight from git: installs fetch it through `.claude-plugin/marketplace.json`, and there is no build step. After changing anything under `plugin/` or `.claude-plugin/`, run `make mint-plugin` before committing. Claude Code only refreshes an installed plugin when its version changes, and CI fails a plugin change that didn't bump it.
|
||||||
|
|
||||||
To create a new migration:
|
The hooks must run on a bare machine: POSIX shell, awk, sed and the usual coreutils, plus `git` and `curl`. They fail open, so a slow or unreachable Scribe never blocks Claude Code.
|
||||||
```bash
|
|
||||||
# Inside the running app container
|
|
||||||
docker compose exec app alembic revision -m "description_of_change"
|
|
||||||
# Edit the generated file in alembic/versions/
|
|
||||||
```
|
|
||||||
|
|
||||||
Migration conventions:
|
## CI and releases
|
||||||
- Use raw SQL with `IF NOT EXISTS` guards for idempotency
|
|
||||||
- Use `DO $$ BEGIN CREATE TYPE … EXCEPTION WHEN duplicate_object THEN NULL; END $$` for enum types
|
|
||||||
- Number migrations sequentially (e.g. `0027_add_something.py`)
|
|
||||||
- Always provide both `upgrade()` and `downgrade()`
|
|
||||||
|
|
||||||
## CI/CD
|
CI runs on Gitea Actions (workflow in `.forgejo/workflows/ci.yml`) in the shared `ci-python:3.14` image ([`ci-requirements.md`](../ci-requirements.md) lists what it relies on). Six jobs: TypeScript typecheck, plugin hooks, Python lint (plus design-token and dangling-style checks), unit tests, integration tests, and the image build, which waits on all of them except the plugin job.
|
||||||
|
|
||||||
### Pipeline
|
| Push | Image tags |
|
||||||
|
|------|-----------|
|
||||||
|
| `dev` | `:dev`, `:<sha>` |
|
||||||
|
| `main` | `:latest`, `:<sha>` |
|
||||||
|
| a `v*` tag | `:latest`, `:<version>`, `:<sha>` |
|
||||||
|
|
||||||
CI runs on Forgejo Actions, consuming the shared
|
`:<sha>` never moves, so it is the rollback point. Images publish to `git.fabledsword.com/bvandeusen/fabledscribe`, with a registry layer cache at `:cache`. The build needs the `REGISTRY_USER` and `REGISTRY_TOKEN` (a token with `write:packages`) repository secrets.
|
||||||
[`ci-python:3.14`](https://git.fabledsword.com/bvandeusen/CI-runner) image
|
|
||||||
via `container.image` (Python 3.14 + Node 24 + ruff + uv + Docker CLI):
|
|
||||||
|
|
||||||
| Trigger | Jobs | Docker tags pushed |
|
Work happens on `dev`. A release merges `dev` into `main` with a plain merge commit; a dated `vYYYY.MM.DD.HHMM` tag, matching the version the image reports, can mark it.
|
||||||
|---------|------|--------------------|
|
|
||||||
| Push to `dev` | typecheck + lint + test → build | `:dev`, `:<sha>` |
|
|
||||||
| Tag `v*` on `main` | typecheck + lint + test → build | `:latest`, `:<version>`, `:<sha>` |
|
|
||||||
| Push to `main` | typecheck + lint + test | (no build) |
|
|
||||||
|
|
||||||
### Release Process
|
## Conventions
|
||||||
|
|
||||||
1. Work on `dev` branch — CI validates on every push
|
|
||||||
2. When ready, open a PR from `dev` → `main` in Forgejo
|
|
||||||
3. Merge the PR
|
|
||||||
4. Create a release via the Forgejo UI on `main` with a `v*` tag (e.g. `v26.03.23.1` — CalVer: `YY.MM.DD.N`)
|
|
||||||
5. The tag push triggers CI → build job pushes `:latest` + `:<version>` Docker images
|
|
||||||
6. After merging to main, sync dev back:
|
|
||||||
```bash
|
|
||||||
git checkout dev && git merge main && git push origin dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Runner
|
|
||||||
|
|
||||||
CI jobs schedule against the `python-ci` runner label and run inside the
|
|
||||||
shared `git.fabledsword.com/bvandeusen/ci-python:3.14` image (see
|
|
||||||
`ci-requirements.md` for what this project relies on from the image).
|
|
||||||
The runner deployment lives outside this repo; image bumps happen in
|
|
||||||
[CI-Runner](https://git.fabledsword.com/bvandeusen/CI-runner) via Renovate.
|
|
||||||
|
|
||||||
`infra/runner-compose.yml` + `infra/act-runner-config.yml` document the
|
|
||||||
runner-host deployment shape; the source of truth is the deployed
|
|
||||||
config on the runner host.
|
|
||||||
|
|
||||||
### Docker Registry
|
|
||||||
|
|
||||||
Images pushed to: `git.fabledsword.com/bvandeusen/scribe`
|
|
||||||
Cache tag: `:cache` (reduces build time ~80%)
|
|
||||||
|
|
||||||
Required secrets (repo → Settings → Secrets → Actions):
|
|
||||||
- `REGISTRY_USER` — Forgejo username
|
|
||||||
- `REGISTRY_TOKEN` — Forgejo PAT with `write:packages` scope
|
|
||||||
|
|
||||||
## Migration Chain
|
|
||||||
|
|
||||||
Current migration sequence (all idempotent raw SQL):
|
|
||||||
|
|
||||||
```
|
|
||||||
0001 create_notes_table
|
|
||||||
0002 create_tasks_table
|
|
||||||
0003 task_note_companion (data migration)
|
|
||||||
0004 merge_tasks_into_notes
|
|
||||||
0005 add_chat_tables
|
|
||||||
0006 add_settings_table
|
|
||||||
0007 add_title_and_updated_at_indexes
|
|
||||||
0008 add_users_and_user_id
|
|
||||||
0009 add_message_status
|
|
||||||
0010 add_app_logs_table
|
|
||||||
0011 add_password_reset_tokens
|
|
||||||
0012 add_invitation_tokens
|
|
||||||
0013 add_tool_calls_to_messages
|
|
||||||
0014 add_note_embeddings
|
|
||||||
0015 add_oauth_fields
|
|
||||||
0016 add_image_cache
|
|
||||||
0017 add_projects
|
|
||||||
0018 add_push_subscriptions
|
|
||||||
0019 add_events (dead code — internal CalDAV/Radicale table; Radicale was removed)
|
|
||||||
0020 add_milestones
|
|
||||||
0021 add_task_logs
|
|
||||||
0022 add_note_versions_and_drafts
|
|
||||||
0023 add_tags_to_note_versions
|
|
||||||
0024 add_session_version
|
|
||||||
0025 add_sharing_and_notifications
|
|
||||||
0026 add_briefing_tables
|
|
||||||
0027 add_api_keys
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** Do NOT use `op.create_table()` or `sa.Enum()` — SQLAlchemy's event system can fire `CREATE TYPE` even with `create_type=False`, causing failures on re-run. Always use raw SQL with `IF NOT EXISTS` / `DO $$ BEGIN ... EXCEPTION WHEN duplicate_object` guards.
|
|
||||||
|
|
||||||
## Project Conventions
|
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
- Services: `async with async_session() as session:` — import from `scribe.models`
|
- Services open their own session: `async with async_session() as session:` (from `scribe.models`).
|
||||||
- No `scribe.database` module
|
- Routes and MCP tools stay thin; the logic lives in `services/`, shared by both.
|
||||||
- Blueprint per resource: `routes/notes.py`, `routes/tasks.py`, etc.
|
- Access checks go through `services/access.py`, never inline ownership checks.
|
||||||
- All business logic in `services/`; routes are thin wrappers
|
- Every wait on something outside the process has a deadline.
|
||||||
- Permission checks via `services/access.py` — never inline ownership checks in routes
|
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
- API calls via `frontend/src/api/client.ts` typed helpers (`apiGet`, `apiPost`, `apiPatch`, `apiDelete`)
|
- API calls go through the typed helpers in `frontend/src/api/client.ts`.
|
||||||
- Pinia stores for shared state; local `ref()` for component-only state
|
- Pinia stores for shared state, `ref()` for state local to a component; composables for reusable behaviour.
|
||||||
- Composables in `composables/` for reusable behaviour (autosave, keyboard nav, tag suggestions, …)
|
- Colours, spacing and type come from the design system's tokens in `theme.css`, which is generated, so CI rejects an unknown `var()`.
|
||||||
- Views are page-level components in `views/`; reusable UI in `components/`
|
|
||||||
|
|
||||||
### Commit Style
|
### Commits
|
||||||
|
|
||||||
```
|
```
|
||||||
type(scope): short description
|
type(scope): what the change does
|
||||||
|
|
||||||
Longer body if needed.
|
Why, and anything a reader tracing this change later needs.
|
||||||
|
|
||||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`
|
Types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`. Comments in the code describe how it works today; the history of a change belongs in its commit message.
|
||||||
Scopes: feature area (e.g. `chat`, `journal`, `mcp`, `notes`)
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
make test
|
|
||||||
|
|
||||||
# Run specific test file
|
|
||||||
docker compose exec app /opt/venv/bin/pytest tests/test_auth.py -v
|
|
||||||
```
|
|
||||||
|
|
||||||
Tests are in `tests/`. They run against a real PostgreSQL instance in CI (not mocked). Keep tests integration-style where possible — mock failures have historically masked real migration bugs.
|
|
||||||
|
|||||||
+84
-133
@@ -1,181 +1,132 @@
|
|||||||
# Features
|
# Features
|
||||||
|
|
||||||
Scribe is a self-hosted work system-of-record for software projects, built to be
|
Scribe is a self-hosted system of record for the work you do with Claude Code. Claude records what it learned, decided and built, and that record reaches the next session, so a solution is recalled rather than reinvented. There is no language model inside Scribe: Claude reaches it through the MCP endpoint and the Claude Code plugin, and the web UI is where people read and edit the same records.
|
||||||
driven by Claude Code. There is **no in-app LLM** — Claude is the sole assistant,
|
|
||||||
reaching Scribe through a built-in MCP endpoint and a bundled Claude Code plugin. The
|
|
||||||
web UI is a clean surface for humans to read and edit the same data.
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
Write in Markdown with a live-preview editor (Tiptap/ProseMirror). Headings, bold,
|
A Markdown editor (Tiptap) with headings, lists, code blocks and checklists inline, and a `/` menu for blocks.
|
||||||
italic, lists, code blocks, and task checklists render inline. A slash-command menu
|
|
||||||
(`/`) inserts common blocks.
|
|
||||||
|
|
||||||
- **Wikilinks** — Link notes with `[[Title]]` or `[[Title|Display Text]]`. Clicking
|
- **Wikilinks**: `[[Title]]` or `[[Title|text]]`, suggested as you type; backlinks show in the viewer.
|
||||||
navigates to (or auto-creates) the referenced note; the editor suggests existing
|
- **Tags**, with autocomplete. Hierarchical tags (`area/backend`) match their parent's filter.
|
||||||
titles while you type `[[`. Backlinks appear in the note viewer sidebar.
|
- **Version history**: browse, diff and restore earlier versions of a note.
|
||||||
- **Tags** — First-class `ARRAY[text]` column with autocomplete. Hierarchical tags
|
- **Draft recovery**: unsaved edits survive a reload.
|
||||||
(`area/backend`) supported — filtering by `area` matches all `area/*` children.
|
|
||||||
- **Version history** — Every body edit snapshots a version (up to 20 per note).
|
|
||||||
Browse, diff, and restore from the editor's History panel.
|
|
||||||
- **Draft recovery** — In-progress edits persist across page loads and are restored
|
|
||||||
when you reopen a note.
|
|
||||||
- **Convert freely** — Turn a note into a task (sets `status=todo`) or back again.
|
|
||||||
|
|
||||||
## Tasks and Issues
|
## Tasks
|
||||||
|
|
||||||
Tasks carry status (`todo` → `in_progress` → `done`/`cancelled`), priority
|
A task is a note with a status (`todo`, `in_progress`, `done`, `cancelled`), plus priority, due date, milestone and parent task.
|
||||||
(`none`/`low`/`medium`/`high`), due date, milestone assignment, and a parent task
|
|
||||||
(sub-tasks). Notes and tasks share one model — a task is a note with a status.
|
|
||||||
|
|
||||||
- **Work logs** — Append timestamped progress entries (with optional duration) to a
|
- **Kinds**: `work`; `issue`, for corrective work, which can link the task it arose from; and `spike`, a time-boxed investigation whose output is an answer.
|
||||||
task without rewriting its body; shown chronologically in the task view.
|
- **Work logs**: timestamped entries that record progress without rewriting the body. They are what the next session reads first.
|
||||||
- **Issues** — A task whose `kind` is corrective: a problem you fixed or are fixing,
|
- **Claims**: a session that logs on a task holds it, so after a context compaction the work comes back to that session, and other sessions can see who is on it.
|
||||||
with symptom → root cause → fix in the body. An issue can link the task it arose
|
- **Recurring tasks** spawn their next occurrence on a schedule.
|
||||||
from and the System(s) it touches.
|
|
||||||
- **Recurring tasks** — An interval or calendar recurrence rule spawns the next
|
|
||||||
occurrence when a task is completed (a background job drains due spawns).
|
|
||||||
- **Sub-tasks** — Any task can have children via `parent_id`; the viewer shows them
|
|
||||||
inline.
|
|
||||||
|
|
||||||
## Projects and Milestones
|
## Projects, milestones and systems
|
||||||
|
|
||||||
- **Projects** — Group related notes and tasks. Title, description, goal, status
|
- **Projects** carry a goal, a status and a colour, and group everything else. Entering a project gives Claude a short primer: its goal, recent plans and their next steps, open tasks, its systems and its design system.
|
||||||
(`active`/`paused`/`completed`/`archived`), and a colour.
|
- **Milestones are plans**: a body holding the design (goal, approach, verification) and ordered step tasks. Starting a plan that matches an active milestone hands back that milestone instead of opening a second one.
|
||||||
- **Milestones** — Ordered stages within a project. A milestone is also the home of a
|
- **Systems** are a project's named areas ("auth", "retrieval"). Records are tagged with them, each has a charter saying what belongs there, and each maps to a canonical area shared across projects.
|
||||||
**plan** — its body holds the design (Goal/Approach/Verification) and its child
|
- **Duplicate gates**: creating a record that reads like an existing one returns the existing one to update instead.
|
||||||
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.
|
|
||||||
|
|
||||||
## Systems
|
## Rules, lessons and moments
|
||||||
|
|
||||||
A **System** is a per-project, reusable, self-describing subsystem or area (e.g.
|
- **Rulebooks → topics → rules.** A rule in a rulebook applies in every project; a project rule applies only there. A project that departs from a global rule says so with an `overrides` relation.
|
||||||
"auth", "billing"). Associate any note, task, or issue with a System so research,
|
- **Rules arrive by retrieval.** Nothing is preloaded: a rule reaches a session when what Claude is about to do matches its trigger (`when_to_apply`), whether that's the operator's message, a command or a file being written.
|
||||||
build-work, and fixes for the same area line up and recurring problem-spots surface.
|
- **Moments**: a rule about *when* something is done (finishing a task, reporting back, pushing) can be mounted on a moment of work, and arrives whenever that moment happens, whatever the words look like. Settings → General shows which actions reach each moment.
|
||||||
|
- **Versions and verification**: every edit to a rule keeps the text it replaced. A rule that asserts a fact about someone else's software can say how to check it and when it stops being true, and a sweep lists rules due for a check.
|
||||||
|
- **Preferences** are rules with the force of guidance rather than obligation.
|
||||||
|
- **Lessons** record something learned the hard way, keyed to the situation where it applies, and link to the rules they are instances of.
|
||||||
|
- **Reply shapes**: the default shape of Claude's replies (completion report, plan for review, question) arrives at the moment it applies. Settings → General shows them.
|
||||||
|
|
||||||
## Rules and Rulebooks
|
## Snippets and the shape ledger
|
||||||
|
|
||||||
Scribe stores the operator's engineering and workflow **rules** so Claude follows them
|
- **Snippets** are recorded prior art: a reusable piece of code with when to reach for it, its signature and its canonical location in a repo. Claude searches them before building and is shown matching ones before it writes a file.
|
||||||
across sessions.
|
- **Forge integration**: with a git forge connected, Scribe checks a snippet's cached body against its source and marks it current or diverged. Push webhooks keep that fresh.
|
||||||
|
- **The shape ledger**: Scribe extracts the code shapes from a bound repo and tracks how each is accounted for: an instance of a snippet, a deliberate variant with its reason, or a one-off. Coverage shows what is still unclassified.
|
||||||
|
|
||||||
- **Rulebooks → topics → rules** — Rules are grouped by topic inside a rulebook.
|
## Family canon
|
||||||
- **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.
|
|
||||||
|
|
||||||
## Stored Processes
|
Projects on the same platform (Android apps, Go services, ...) share good ideas. An idea promoted to the family's canon reaches every project on that platform, and each answers it: adopted, a variant with its reason, or owed (filed as a task). `/family` shows the ideas and every project's answers.
|
||||||
|
|
||||||
Reusable saved prompts (a note with `note_type=process`) — e.g. a drift-audit or a
|
## Design systems
|
||||||
DRY pass. The bundled plugin syncs each Process into a local Claude Code skill stub
|
|
||||||
(`/scribe:sync`) that auto-surfaces by relevance and fetches the live procedure on
|
|
||||||
demand.
|
|
||||||
|
|
||||||
## Search and Knowledge Injection
|
Tokens (colour, type, space, motion, ...) and written guidance, with inheritance: a project's design system can extend a house style and record where it departs. Scribe generates a stylesheet from it, checks code snippets against it, and gives Claude the values to use before it writes UI. `/design-systems` edits them.
|
||||||
|
|
||||||
- **Semantic search** — pgvector-backed similarity search over notes and tasks
|
## Stored processes
|
||||||
(in-process `fastembed` embeddings; no external model).
|
|
||||||
- **Proactive knowledge-injection** — the plugin's `UserPromptSubmit` hook surfaces a
|
|
||||||
short, high-confidence menu of maybe-relevant note *titles* into Claude's context
|
|
||||||
each turn; Claude pulls a full body only when it judges it relevant. Gated so it
|
|
||||||
stays quiet on most turns and never repeats within a session.
|
|
||||||
|
|
||||||
## Knowledge Graph
|
Saved procedures, such as a drift audit or a DRY pass. The plugin syncs each one into a Claude Code skill (`/scribe:sync`), which fetches the live procedure when it is invoked.
|
||||||
|
|
||||||
`/graph` renders notes, tasks, and tags as a D3 force-directed graph. Tag nodes
|
## Search and context injection
|
||||||
cluster notes that share tags; invisible project hubs attract project members. Physics
|
|
||||||
controls (repulsion, link distance/strength, hub pull, gravity); click a node to peek,
|
- **Semantic search** over notes, tasks, rules, milestones and systems, with embeddings computed in-process (no external model service). It falls back to keyword search if the embedder is unavailable.
|
||||||
click a tag to filter.
|
- **Context injection**: the plugin's hooks show Claude a short menu of possibly relevant records at the moments they matter: rules and prior art when a prompt arrives, before a command or a file write, and when a moment of work is reached. Claude opens a full record only when it judges it relevant.
|
||||||
|
- **Tuning**: each surface has its own thresholds and budget, in Settings → General. Every change is logged with its reason, and retrieval telemetry shows what was surfaced and what was opened.
|
||||||
|
|
||||||
## Claude via MCP and the plugin
|
## Claude via MCP and the plugin
|
||||||
|
|
||||||
The whole store is reachable by Claude through a built-in **MCP endpoint at `/mcp`**
|
The whole store is reachable through the MCP endpoint at `/mcp`, authenticated with an API key. The Claude Code plugin in this repo adds:
|
||||||
(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
|
- hooks for session start, each prompt, before and after tool calls, the end of a turn, compaction and session end, all failing open if Scribe is unreachable;
|
||||||
context so Scribe surfaces without being asked (fail-open if Scribe is unreachable);
|
- skills for the working practices: using Scribe, writing plans, systematic debugging, verification, brainstorming, reporting back, reusing code, shape accounting and family canon;
|
||||||
- universal process-skills — writing-plans, systematic-debugging, verification,
|
- your stored processes as skills.
|
||||||
brainstorming — that route their output into Scribe;
|
|
||||||
- your saved Processes auto-surfaced as skills.
|
|
||||||
|
|
||||||
See [API Keys & MCP](api-keys-and-mcp.md).
|
See [API Keys & MCP](api-keys-and-mcp.md).
|
||||||
|
|
||||||
## Sharing and Collaboration
|
## The web UI
|
||||||
|
|
||||||
- **Share** — Share any project, note, or task with users or groups at
|
- **Dashboard**: what to work on next. **Knowledge** browses everything.
|
||||||
`viewer`/`editor`/`admin` levels from the viewer/project toolbar.
|
- **Graph** (`/graph`): notes, tasks and tags as a force-directed graph, with physics controls and filtering by tag.
|
||||||
- **Groups** — Admins create platform-wide groups and assign `member`/`owner` roles;
|
- **Trash**: deleted records wait here until retention purges them, and can be restored.
|
||||||
share a resource with a group in one action.
|
- **Installable** as a desktop or mobile app.
|
||||||
- **Shared with me** — `/shared` lists incoming shares with permission badges.
|
|
||||||
- **Notifications** — An in-app bell (unread count, polled) fires when a project or
|
|
||||||
note is shared with you or you're added to a group.
|
|
||||||
|
|
||||||
Every read and mutation is scoped by owner + direct shares + group shares.
|
## Sharing
|
||||||
|
|
||||||
## Data Export and Backup
|
Share a project, note or task with users or groups, as viewer, editor or admin. Admins manage groups. `/shared` lists what has been shared with you, and an in-app bell notifies you of new shares. Every read and write is checked against ownership and shares.
|
||||||
|
|
||||||
- **Personal export** — download all your notes/tasks as a Markdown ZIP (YAML
|
## Export and backup
|
||||||
frontmatter) or a JSON array.
|
|
||||||
- **Admin backup** — full application backup/restore 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
|
- **Personal export**: your notes and tasks as a Markdown ZIP (with YAML frontmatter) or JSON.
|
||||||
|
- **Admin backup and restore**, with ids remapped so a backup restores onto another instance.
|
||||||
|
- **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).
|
||||||
|
|
||||||
Installable as a desktop or mobile app; a service worker caches the shell.
|
## Sign-in
|
||||||
|
|
||||||
## Authentication
|
Email and password, plus optional OIDC single sign-on (Authentik, Keycloak, ...) that links to a matching local account. Invite links, password reset and signing out other sessions are included. See [SSO / OAuth](sso-oauth.md).
|
||||||
|
|
||||||
Native email + password, plus optional **OIDC** sign-in (Authentik, Keycloak, etc.)
|
|
||||||
that links to a matching local account. Invite links, a registration toggle, password
|
|
||||||
reset, and session invalidation are included. See [SSO / OAuth](sso-oauth.md).
|
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
| Tab | Contents |
|
| Tab | Contents |
|
||||||
|-----|----------|
|
|-----|----------|
|
||||||
| General | Instance preferences (key/value) |
|
| General | Timezone, trash retention, retrieval thresholds, moments, reply shapes |
|
||||||
| Account | Email change, password change, session invalidation |
|
| Account | Email, password, active sessions |
|
||||||
| Profile | Per-user profile fields |
|
| Profile | About you, response preferences, interests, work schedule |
|
||||||
| Integrations | SearXNG status |
|
| Notifications | Email notifications |
|
||||||
| Data | Personal export; backup / restore and scheduled backups (admin) |
|
| Integrations | SearXNG, git forge connections |
|
||||||
| API Keys | Create/revoke `fmcp_` keys for the MCP endpoint |
|
| Data | Export, backup and restore, scheduled backups (admin) |
|
||||||
| Config (admin) | Base URL, SMTP, DB-maintenance schedule |
|
| MCP Access | API keys and the command to connect Claude |
|
||||||
| Users (admin) | User list, invite links, registration toggle |
|
| Config (admin) | What's running, application URL, plugin marketplace, database maintenance, SMTP, forge webhook |
|
||||||
| Logs (admin) | Error, audit, and usage logs with search |
|
| Shared areas (admin) | The canonical areas systems map to |
|
||||||
| Groups (admin) | Create/manage groups and membership |
|
| Platforms (admin) | The platform catalogue family canon is grouped by |
|
||||||
|
| Users (admin) | Registration, invites, users |
|
||||||
|
| Logs (admin) | Error, audit and usage logs |
|
||||||
|
| Groups (admin) | Groups and membership |
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
## Keyboard shortcuts
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
| `g` + `h` | Home (dashboard) |
|
| `g` `h` | Dashboard |
|
||||||
| `g` + `n` | Notes |
|
| `g` `n` | Notes |
|
||||||
| `g` + `t` | Knowledge (tasks) |
|
| `g` `t` | Knowledge (everything, tasks included) |
|
||||||
| `g` + `p` | Projects |
|
| `g` `p` | Projects |
|
||||||
| `g` + `r` | Rulebooks |
|
| `g` `r` | Rules |
|
||||||
| `g` (bare) | Graph |
|
| `g` `g` | Graph |
|
||||||
| `g` + `x` | Trash |
|
| `g` `x` | Trash |
|
||||||
| `n` | New note |
|
| `n` | New note |
|
||||||
| `t` | New task |
|
| `t` | New task |
|
||||||
|
| `e` | Edit the note you're viewing |
|
||||||
| `/` | Focus search |
|
| `/` | Focus search |
|
||||||
| `?` | Show shortcuts panel |
|
| `?` | Show shortcuts |
|
||||||
| `j` / `k` | Navigate list items |
|
| `j` / `k` | Move through a list |
|
||||||
| `Enter` | Open selected item |
|
| `Enter` | Open the selected item |
|
||||||
| `e` | Edit current item |
|
| `Esc` | Close the overlay, leave the field, then go to the dashboard |
|
||||||
| `Esc` | Close panel / blur / go home (progressive) |
|
| `Ctrl+S` | Save in the editor |
|
||||||
| `Ctrl+S` | Save in editor |
|
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
# OAuth / OIDC SSO Setup
|
# OAuth / OIDC SSO Setup
|
||||||
|
|
||||||
Fabled Assistant supports single sign-on via any OpenID Connect provider.
|
Scribe supports single sign-on via any OpenID Connect provider.
|
||||||
This guide covers Authentik, but the same pattern works with Keycloak, Authelia, Zitadel, etc.
|
This guide covers Authentik, but the same pattern works with Keycloak, Authelia, Zitadel, etc.
|
||||||
|
|
||||||
## 1. Create the provider in Authentik
|
## 1. Create the provider in Authentik
|
||||||
@@ -8,7 +8,7 @@ This guide covers Authentik, but the same pattern works with Keycloak, Authelia,
|
|||||||
1. Log in to the Authentik admin UI.
|
1. Log in to the Authentik admin UI.
|
||||||
2. Go to **Applications → Providers → Create → OAuth2/OpenID Provider**.
|
2. Go to **Applications → Providers → Create → OAuth2/OpenID Provider**.
|
||||||
3. Fill in:
|
3. Fill in:
|
||||||
- **Name:** `Fabled Assistant` (or whatever you like)
|
- **Name:** `Scribe` (or whatever you like)
|
||||||
- **Authorization flow:** your default authorization flow
|
- **Authorization flow:** your default authorization flow
|
||||||
- **Client type:** `Confidential`
|
- **Client type:** `Confidential`
|
||||||
- **Redirect URIs:** `https://your-fabled-domain/api/auth/oauth/callback`
|
- **Redirect URIs:** `https://your-fabled-domain/api/auth/oauth/callback`
|
||||||
@@ -20,7 +20,7 @@ This guide covers Authentik, but the same pattern works with Keycloak, Authelia,
|
|||||||
https://auth.example.com/application/o/fabled-assistant/
|
https://auth.example.com/application/o/fabled-assistant/
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Configure Fabled Assistant
|
## 2. Configure Scribe
|
||||||
|
|
||||||
Add the following environment variables to the `app` service in `docker-compose.yml`:
|
Add the following environment variables to the `app` service in `docker-compose.yml`:
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ docker compose up --build -d
|
|||||||
|
|
||||||
## 4. Account Linking
|
## 4. Account Linking
|
||||||
|
|
||||||
When a user logs in via OAuth for the first time, Fabled checks in this order:
|
When a user logs in via OAuth for the first time, Scribe checks in this order:
|
||||||
|
|
||||||
1. **Existing OAuth sub** — returns that user immediately.
|
1. **Existing OAuth sub** — returns that user immediately.
|
||||||
2. **Matching email** — if a local account already exists with the same email, the OAuth identity is linked to it automatically. The user retains all their notes and tasks.
|
2. **Matching email** — if a local account already exists with the same email, the OAuth identity is linked to it automatically. The user retains all their notes and tasks.
|
||||||
@@ -92,4 +92,4 @@ The backend will reject any `POST /api/auth/login` or `POST /api/auth/register`
|
|||||||
| Zitadel | `https://your-instance.zitadel.cloud` |
|
| Zitadel | `https://your-instance.zitadel.cloud` |
|
||||||
| Google | `https://accounts.google.com` |
|
| Google | `https://accounts.google.com` |
|
||||||
|
|
||||||
The OIDC discovery endpoint (`<issuer>/.well-known/openid-configuration`) must be publicly reachable from the Fabled container (server-to-server call at login time).
|
The OIDC discovery endpoint (`<issuer>/.well-known/openid-configuration`) must be publicly reachable from the Scribe container (server-to-server call at login time).
|
||||||
|
|||||||
+19
-8
@@ -13,11 +13,10 @@ import { fetchVersion } from "@/api/version";
|
|||||||
useTheme();
|
useTheme();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// THREE states, not two (#3127 checklist 12). `null` is "not answered yet" and
|
// THREE states, not two. `null` is "not answered yet" and renders nothing; a
|
||||||
// renders nothing; a string renders; `appVersionFailed` renders its own thing.
|
// string renders; `appVersionFailed` renders its own thing. Defaulting to the
|
||||||
// This used to default to the literal "dev" and swallow the error, which meant
|
// literal "dev" and swallowing the error would make an instance that cannot
|
||||||
// an instance that could not answer was indistinguishable from a local build
|
// answer indistinguishable from a local build that genuinely reports "dev" — a blank standing in for `unknown`, in the one
|
||||||
// that genuinely reports "dev" — a blank standing in for `unknown`, in the one
|
|
||||||
// readout whose whole job is to say what is running.
|
// readout whose whole job is to say what is running.
|
||||||
const appVersion = ref<string | null>(null);
|
const appVersion = ref<string | null>(null);
|
||||||
const appVersionFailed = ref(false);
|
const appVersionFailed = ref(false);
|
||||||
@@ -85,7 +84,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
|||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case "h": router.push("/"); break;
|
case "h": router.push("/"); break;
|
||||||
case "n": router.push("/notes"); break;
|
case "n": router.push("/notes"); break;
|
||||||
case "t": router.push("/"); break;
|
case "t": router.push("/knowledge"); break;
|
||||||
case "p": router.push("/projects"); break;
|
case "p": router.push("/projects"); break;
|
||||||
case "r": router.push("/rules"); break;
|
case "r": router.push("/rules"); break;
|
||||||
case "g": router.push("/graph"); break;
|
case "g": router.push("/graph"); break;
|
||||||
@@ -182,7 +181,7 @@ onUnmounted(() => {
|
|||||||
<kbd class="shortcut-key">g</kbd>
|
<kbd class="shortcut-key">g</kbd>
|
||||||
<span class="shortcut-key-sep">+</span>
|
<span class="shortcut-key-sep">+</span>
|
||||||
<kbd class="shortcut-key">h</kbd>
|
<kbd class="shortcut-key">h</kbd>
|
||||||
<span class="shortcut-desc">Home</span>
|
<span class="shortcut-desc">Dashboard</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="shortcut-row">
|
<div class="shortcut-row">
|
||||||
<kbd class="shortcut-key">g</kbd>
|
<kbd class="shortcut-key">g</kbd>
|
||||||
@@ -202,6 +201,18 @@ onUnmounted(() => {
|
|||||||
<kbd class="shortcut-key">p</kbd>
|
<kbd class="shortcut-key">p</kbd>
|
||||||
<span class="shortcut-desc">Projects</span>
|
<span class="shortcut-desc">Projects</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="shortcut-row">
|
||||||
|
<kbd class="shortcut-key">g</kbd>
|
||||||
|
<span class="shortcut-key-sep">+</span>
|
||||||
|
<kbd class="shortcut-key">r</kbd>
|
||||||
|
<span class="shortcut-desc">Rules</span>
|
||||||
|
</div>
|
||||||
|
<div class="shortcut-row">
|
||||||
|
<kbd class="shortcut-key">g</kbd>
|
||||||
|
<span class="shortcut-key-sep">+</span>
|
||||||
|
<kbd class="shortcut-key">g</kbd>
|
||||||
|
<span class="shortcut-desc">Graph</span>
|
||||||
|
</div>
|
||||||
<div class="shortcut-row">
|
<div class="shortcut-row">
|
||||||
<kbd class="shortcut-key">g</kbd>
|
<kbd class="shortcut-key">g</kbd>
|
||||||
<span class="shortcut-key-sep">+</span>
|
<span class="shortcut-key-sep">+</span>
|
||||||
@@ -210,7 +221,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="shortcut-row">
|
<div class="shortcut-row">
|
||||||
<kbd class="shortcut-key">Esc</kbd>
|
<kbd class="shortcut-key">Esc</kbd>
|
||||||
<span class="shortcut-desc">Unfocus field → go home</span>
|
<span class="shortcut-desc">Unfocus field → go to the dashboard</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="shortcut-row">
|
<div class="shortcut-row">
|
||||||
<kbd class="shortcut-key">?</kbd>
|
<kbd class="shortcut-key">?</kbd>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { apiGet, apiPost, apiPut } from "@/api/client";
|
import { apiGet, apiPost, apiPut } from "@/api/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scheduled backups (milestone 523) — `/api/admin/backups`. One dated,
|
* Scheduled backups — `/api/admin/backups`. One dated,
|
||||||
* uncompressed tar per run, delivered to a folder or an S3 bucket.
|
* uncompressed tar per run, delivered to a folder or an S3 bucket.
|
||||||
*/
|
*/
|
||||||
export interface BackupConfig {
|
export interface BackupConfig {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Canonical systems — the GLOBAL area vocabulary every project's Systems can
|
* Canonical systems — the GLOBAL area vocabulary every project's Systems can
|
||||||
* map onto (milestone 307).
|
* map onto.
|
||||||
*
|
*
|
||||||
* The mapping is an ASSOCIATION, never a rename: a project's System keeps the
|
* 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
|
* name the project gave it, and `canonical_id` only records which shared area
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ async function handleResponse<T>(res: Response, path: string): Promise<T> {
|
|||||||
/**
|
/**
|
||||||
* The server's `{"error": "..."}` message from a failed call, or `fallback`
|
* The server's `{"error": "..."}` message from a failed call, or `fallback`
|
||||||
* when the failure carried none (network error, non-JSON body). The one place
|
* 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
|
* the error envelope is unpacked on the client, so no view restates it as a
|
||||||
* as a six-line `"body" in e` branch at every catch site.
|
* `"body" in e` branch at its catch site.
|
||||||
*/
|
*/
|
||||||
export function apiErrorMessage(e: unknown, fallback: string): string {
|
export function apiErrorMessage(e: unknown, fallback: string): string {
|
||||||
if (e && typeof e === "object" && "body" in e) {
|
if (e && typeof e === "object" && "body" in e) {
|
||||||
@@ -55,14 +55,13 @@ export function apiErrorMessage(e: unknown, fallback: string): string {
|
|||||||
/**
|
/**
|
||||||
* How long an ordinary JSON call may wait before it is declared failed.
|
* How long an ordinary JSON call may wait before it is declared failed.
|
||||||
*
|
*
|
||||||
* Rule 156: a wait with no deadline is a bug. `fetch`'s own default is to wait
|
* A wait with no deadline is a bug. `fetch`'s own default is to wait
|
||||||
* as long as the browser will, which is not a deadline — it is the absence of
|
* 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
|
* 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.
|
* 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
|
* 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
|
* embedding call and a list view under connection-pool contention, so
|
||||||
* /api/projects fanning 25 concurrent sessions at a 15-connection pool), so
|
|
||||||
* tripping it means something is genuinely wrong rather than merely busy. Slow
|
* 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
|
* BY DESIGN is a different case and passes its own value — see the callers in
|
||||||
* SettingsView that do.
|
* SettingsView that do.
|
||||||
@@ -118,7 +117,7 @@ export interface RequestOpts {
|
|||||||
* The one place a request is actually made — every verb below goes through
|
* The one place a request is actually made — every verb below goes through
|
||||||
* here, so the deadline cannot be forgotten by adding a sixth.
|
* here, so the deadline cannot be forgotten by adding a sixth.
|
||||||
*
|
*
|
||||||
* EXPIRY SURFACES AS AN `ApiError`, which is rule 156's second half: the
|
* EXPIRY SURFACES AS AN `ApiError`, the deadline's second half: the
|
||||||
* failure has to arrive in the shape the caller already handles. A bare
|
* failure has to arrive in the shape the caller already handles. A bare
|
||||||
* `DOMException: TimeoutError` would reach `apiErrorMessage(e, fallback)` as
|
* `DOMException: TimeoutError` would reach `apiErrorMessage(e, fallback)` as
|
||||||
* an object with no `body`, so every catch site in the app would report its
|
* an object with no `body`, so every catch site in the app would report its
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Design systems — the stylesheet held as records (milestone #254).
|
* Design systems — the stylesheet held as records.
|
||||||
*
|
*
|
||||||
* A design system is a named set of tokens with an optional parent. A system
|
* A design system is a named set of tokens with an optional parent. A system
|
||||||
* with no parent is a "family"; one with a parent holds ONLY what it changes,
|
* with no parent is a "family"; one with a parent holds ONLY what it changes,
|
||||||
@@ -82,8 +82,8 @@ export interface StarterRoleGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The starter token ROLES offered at creation — names and purposes, never
|
/** The starter token ROLES offered at creation — names and purposes, never
|
||||||
* values. A default palette would be one install's taste shipped as product
|
* 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. */
|
* so the values are always the operator's to fill. */
|
||||||
export const listStarterRoleGroups = () =>
|
export const listStarterRoleGroups = () =>
|
||||||
apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>(
|
apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>(
|
||||||
"/api/design-systems/starter-roles",
|
"/api/design-systems/starter-roles",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Family canon (milestone 463): ideas every project on a platform shares, and
|
* Family canon: ideas every project on a platform shares, and
|
||||||
* the log of every decision about them.
|
* the log of every decision about them.
|
||||||
*
|
*
|
||||||
* The agent promotes by written criteria — no approval step. This door is for
|
* The agent promotes by written criteria — no approval step. This door is for
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/** Project inception (milestone 297): what a project was decided to inherit. */
|
/** Project inception: what a project was decided to inherit. */
|
||||||
import { apiGet, apiPost } from "@/api/client";
|
import { apiGet, apiPost } from "@/api/client";
|
||||||
import type { ProjectPlatform } from "@/api/platforms";
|
import type { ProjectPlatform } from "@/api/platforms";
|
||||||
|
|
||||||
export interface InceptionChoices {
|
export interface InceptionChoices {
|
||||||
design_system_id: number | null;
|
design_system_id: number | null;
|
||||||
seed_systems: boolean;
|
seed_systems: boolean;
|
||||||
/** Platform slugs (milestone 463). null = not answered; a list is the
|
/** Platform slugs. null = not answered; a list is the
|
||||||
* whole answer — members left out of it are recorded as "not this".
|
* whole answer — members left out of it are recorded as "not this".
|
||||||
* Absent on records decided before the question existed. */
|
* Absent on records decided before the question existed. */
|
||||||
platforms?: string[] | null;
|
platforms?: string[] | null;
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export interface Lesson {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
note_type: string;
|
note_type: string;
|
||||||
/** Where it was LEARNED. Kept as a fact, but not a limit on where it can be
|
/** 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). */
|
* found: a lesson is retrievable from every project. */
|
||||||
project_id: number | null;
|
project_id: number | null;
|
||||||
permission?: string;
|
permission?: string;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
@@ -88,7 +88,7 @@ export interface Lesson {
|
|||||||
no_rule?: { why: string; judged_at: string | null };
|
no_rule?: { why: string; judged_at: string | null };
|
||||||
/** Sent by a create or update that recorded "no rule fits" when that answer
|
/** 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
|
* completes a group: lessons with no rule that keep landing in one
|
||||||
* situation, which may want a rule written for it (#4634). */
|
* situation, which may want a rule written for it. */
|
||||||
convergence?: { lessons: { id: number; title: string }[] };
|
convergence?: { lessons: { id: number; title: string }[] };
|
||||||
/** Set when another user owns this record. */
|
/** Set when another user owns this record. */
|
||||||
shared?: boolean;
|
shared?: boolean;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { apiGet, apiPost, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiDelete } from "@/api/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The moments of work rules mount on (milestone 458), and the actions that
|
* The moments of work rules mount on, and the actions that
|
||||||
* reach each one on this install — `GET /api/retrieval/moments`, the payload
|
* 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
|
* the `list_moments` MCP tool returns, plus the counts the Settings view shows
|
||||||
* beside each moment.
|
* beside each moment.
|
||||||
@@ -102,9 +102,8 @@ export function unmapAction(change: MappingChange): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Proposals about a rule's moments, waiting on a person. A `mount` proposal
|
* 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
|
* comes from a pass that read the rule, or from the rule being opened just
|
||||||
* rule being opened just after the moment fired. An `unmount` proposal (step
|
* after the moment fired. An `unmount` proposal comes from sessions reporting that the mount arrived where it did not
|
||||||
* 7b) comes from sessions reporting that the mount arrived where it did not
|
|
||||||
* apply. `GET /api/retrieval/moments/proposals`, the payload the
|
* apply. `GET /api/retrieval/moments/proposals`, the payload the
|
||||||
* `rule_moment_proposals` MCP tool returns.
|
* `rule_moment_proposals` MCP tool returns.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Platforms — what a project is built on or ships as (milestone 463).
|
* Platforms — what a project is built on or ships as.
|
||||||
*
|
*
|
||||||
* The catalog is GLOBAL, like the canonical areas, and admin-written. A
|
* The catalog is GLOBAL, like the canonical areas, and admin-written. A
|
||||||
* project's answer per platform is `declared` (a person said so), `detected`
|
* project's answer per platform is `declared` (a person said so), `detected`
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { apiGet } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The reply shapes Scribe ships (milestone 500) — `GET /api/retrieval/reply-shapes`.
|
* The reply shapes Scribe ships — `GET /api/retrieval/reply-shapes`.
|
||||||
* The same catalog the `list_reply_shapes` MCP tool returns, plus what only
|
* 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,
|
* the operator's view needs: the preferences mounted on each shape's moment,
|
||||||
* and how often each shape was delivered.
|
* and how often each shape was delivered.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { RecordUsage } from "@/types/usage";
|
|||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||||
import type { LinkState } from "@/api/lessons";
|
import type { LinkState } from "@/api/lessons";
|
||||||
|
|
||||||
/** How a rule reaches a session (milestone 307). */
|
/** How a rule reaches a session. */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A typed edge between two rules. Each kind exists because its absence forced
|
* A typed edge between two rules. Each kind exists because its absence forced
|
||||||
@@ -56,7 +56,7 @@ export type RuleKind = "rule" | "preference";
|
|||||||
|
|
||||||
/** A new record opened from somewhere that already knows what it is for —
|
/** 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
|
* "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. */
|
* moment. The editor applies it once, when creating. */
|
||||||
export interface RulePreset {
|
export interface RulePreset {
|
||||||
kind?: RuleKind;
|
kind?: RuleKind;
|
||||||
moments?: string[];
|
moments?: string[];
|
||||||
@@ -91,11 +91,11 @@ export interface Rule {
|
|||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
/** Present only when the rule has them (the server omits empty keys). */
|
/** Present only when the rule has them (the server omits empty keys). */
|
||||||
systems?: { id: number; name: string }[];
|
systems?: { id: number; name: string }[];
|
||||||
/** The moments this rule arrives at whenever they happen (milestone 458),
|
/** The moments this rule arrives at whenever they happen,
|
||||||
* in catalog order. Present only when the rule is mounted. */
|
* in catalog order. Present only when the rule is mounted. */
|
||||||
moments?: string[];
|
moments?: string[];
|
||||||
relations?: RuleRelation[];
|
relations?: RuleRelation[];
|
||||||
/** The lessons that point at this rule (milestone 440) — the concrete
|
/** The lessons that point at this rule — the concrete
|
||||||
* situations judged instances of it, plus any suggested and awaiting a
|
* situations judged instances of it, plus any suggested and awaiting a
|
||||||
* judgment. Readable lessons only; omitted when there are none. */
|
* judgment. Readable lessons only; omitted when there are none. */
|
||||||
lessons?: { id: number; title: string; state: LinkState; note: string }[];
|
lessons?: { id: number; title: string; state: LinkState; note: string }[];
|
||||||
@@ -124,7 +124,7 @@ export interface RuleHeader {
|
|||||||
*/
|
*/
|
||||||
last_verified?: string;
|
last_verified?: string;
|
||||||
/**
|
/**
|
||||||
* Surfaced-vs-opened counts from `rule_usage_events` (milestone 333).
|
* Surfaced-vs-opened counts from `rule_usage_events`.
|
||||||
* Zero-filled by the list route, so a rule predating the table reads as
|
* 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
|
* "never surfaced" rather than as a missing field — which for a while is
|
||||||
* every rule on every install.
|
* every rule on every install.
|
||||||
@@ -238,7 +238,7 @@ export async function updateRule(id: number, data: Partial<RuleWrite>): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Give a rule a new home: a topic makes it global, a project makes it that
|
/** 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). */
|
* project's. Keeps its id, history, areas and relations. */
|
||||||
export async function moveRule(
|
export async function moveRule(
|
||||||
id: number, to: { topic_id: number } | { project_id: number },
|
id: number, to: { topic_id: number } | { project_id: number },
|
||||||
): Promise<Rule> {
|
): Promise<Rule> {
|
||||||
@@ -295,7 +295,7 @@ export async function getRuleVersion(
|
|||||||
return apiGet<RuleVersion>(`/api/rules/${ruleId}/versions/${versionId}`);
|
return apiGet<RuleVersion>(`/api/rules/${ruleId}/versions/${versionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No restore FOR A RULE, deliberately (milestone 323). Putting an old wording
|
// No restore FOR A RULE, deliberately. Putting an old wording
|
||||||
// back goes through updateRule, which snapshots what it replaces — so the
|
// back goes through updateRule, which snapshots what it replaces — so the
|
||||||
// undo stays visible in the history like any other edit.
|
// undo stays visible in the history like any other edit.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export interface SnippetFields {
|
|||||||
* no `locations`/`tags` predates that attribution and cannot be un-merged. */
|
* no `locations`/`tags` predates that attribution and cannot be un-merged. */
|
||||||
merged_from: { id: number; locations?: SnippetLocation[]; tags?: string[] }[];
|
merged_from: { id: number; locations?: SnippetLocation[]; tags?: string[] }[];
|
||||||
code: string;
|
code: string;
|
||||||
/** Free text that is not the situation — why, history, caveats (#4378).
|
/** Free text that is not the situation — why, history, caveats.
|
||||||
* Kept out of `when_to_use`, which the snippet is ranked on. */
|
* Kept out of `when_to_use`, which the snippet is ranked on. */
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ export interface Snippet {
|
|||||||
|
|
||||||
/** Kept as a name because every consumer here says "snippet usage" — but it IS
|
/** 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
|
* the shared shape, since rules answer the same question off their own table
|
||||||
* (milestone 333). The reasoning lives on `RecordUsage`; duplicating the four
|
* The reasoning lives on `RecordUsage`; duplicating the four
|
||||||
* fields here is how the two drift. */
|
* fields here is how the two drift. */
|
||||||
export type SnippetUsage = RecordUsage;
|
export type SnippetUsage = RecordUsage;
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ export interface SnippetListItem {
|
|||||||
* written, which is a shape to adapt rather than code to paste. */
|
* written, which is a shape to adapt rather than code to paste. */
|
||||||
language?: string;
|
language?: string;
|
||||||
/** When to reach for it, projected from the `data` mirror. The title is the
|
/** 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. */
|
* name alone, so this is where the situation lives. */
|
||||||
when_to_use?: string;
|
when_to_use?: string;
|
||||||
/** Always present from the backend, zero-filled for records with no events. */
|
/** Always present from the backend, zero-filled for records with no events. */
|
||||||
usage?: SnippetUsage;
|
usage?: SnippetUsage;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface System {
|
|||||||
open_issue_count: number;
|
open_issue_count: number;
|
||||||
/**
|
/**
|
||||||
* How often this area's rulings were shown to a session because its files
|
* 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).
|
* were touched (`surfaced_count`), and how often it was opened.
|
||||||
* Present on the list and detail reads; optional because a System returned
|
* Present on the list and detail reads; optional because a System returned
|
||||||
* by a create or update carries none.
|
* by a create or update carries none.
|
||||||
*/
|
*/
|
||||||
@@ -45,7 +45,7 @@ export async function listSystems(projectId: number): Promise<System[]> {
|
|||||||
* caller to accept or ignore.
|
* caller to accept or ignore.
|
||||||
*
|
*
|
||||||
* A same-named System in this project is a 409 ApiError carrying
|
* A same-named System in this project is a 409 ApiError carrying
|
||||||
* `{duplicate, existing_id}` — the same gate the MCP door enforces (#2482).
|
* `{duplicate, existing_id}` — the same gate the MCP door enforces.
|
||||||
*/
|
*/
|
||||||
export interface CreatedSystem extends System {
|
export interface CreatedSystem extends System {
|
||||||
canonical_suggestion?: CanonicalMatch;
|
canonical_suggestion?: CanonicalMatch;
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
*
|
*
|
||||||
* WHY THIS FILE EXISTS
|
* WHY THIS FILE EXISTS
|
||||||
*
|
*
|
||||||
* `.btn-primary` was defined five times, in five scoped stylesheets, and all
|
* A recipe copied into several scoped stylesheets drifts — paddings, font
|
||||||
* five had drifted: three paddings, three font sizes, three disabled opacities,
|
* sizes, disabled states — and nothing detects it, because a scoped duplicate
|
||||||
* and one view with no disabled style at all (#2273). Nothing detected that,
|
* is invisible to every tool: it isn't a rule violation, isn't a broken
|
||||||
* because a scoped duplicate is invisible to every tool — it isn't a rule
|
* reference, and isn't a recorded snippet. Shared recipes live here instead.
|
||||||
* violation, isn't a broken reference, and isn't a recorded snippet.
|
|
||||||
*
|
*
|
||||||
* GEOMETRY LIVES HERE. Every value is a design-system token, so a palette or
|
* GEOMETRY LIVES HERE. Every value is a design-system token, so a palette or
|
||||||
* scale change moves the buttons rather than stranding a copy that no longer
|
* scale change moves the buttons rather than stranding a copy that no longer
|
||||||
@@ -301,9 +300,7 @@
|
|||||||
/* ── Page container ─────────────────────────────────────────────────────────
|
/* ── Page container ─────────────────────────────────────────────────────────
|
||||||
The one wrapper a top-level view sits in: page width from the layout
|
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)
|
tokens, centred, clipped horizontally so a wide child (a kanban, a table)
|
||||||
scrolls inside itself instead of the page. ProjectListView, ProjectView and
|
scrolls inside itself instead of the page. */
|
||||||
SnippetListView each carried this rule under their own name until #2903
|
|
||||||
(milestone 299). */
|
|
||||||
.page-container {
|
.page-container {
|
||||||
max-width: var(--fs-layout-page-max);
|
max-width: var(--fs-layout-page-max);
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
@@ -311,12 +308,11 @@
|
|||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Form input (fs-surfaces, snippet #2336) ────────────────────────────────
|
/* ── Form input (fs-surfaces) ───────────────────────────────────────────────
|
||||||
Inputs sit DARKER than the page they're on — an inset well rather than a
|
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
|
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
|
design system's recipe, verbatim; width/box-sizing stay the caller's
|
||||||
(an inline select and a full-width textarea differ there). Three scoped
|
(an inline select and a full-width textarea differ there). */
|
||||||
copies of an older input recipe were folded into this in #2903. */
|
|
||||||
.fs-input {
|
.fs-input {
|
||||||
background: var(--fs-surface-page);
|
background: var(--fs-surface-page);
|
||||||
border: var(--fs-border);
|
border: var(--fs-border);
|
||||||
@@ -331,8 +327,8 @@
|
|||||||
.fs-input:focus { outline: none; box-shadow: var(--fs-focus-ring); }
|
.fs-input:focus { outline: none; box-shadow: var(--fs-focus-ring); }
|
||||||
.fs-input:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
|
.fs-input:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
|
||||||
|
|
||||||
/* Page scaffold + feedback text recipes (milestone 302, note 2917): name
|
/* Page scaffold + feedback text recipes: families that are one recipe
|
||||||
families the consumer map showed to be one recipe living in many views.
|
living in many views.
|
||||||
A view keeps only its deviation as a scoped remainder/override. */
|
A view keeps only its deviation as a scoped remainder/override. */
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -355,9 +351,8 @@
|
|||||||
/* --- usage badge ----------------------------------------------------------
|
/* --- usage badge ----------------------------------------------------------
|
||||||
"surfaced N×, opened M×" on a list row, for any record kind the retrieval
|
"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
|
surfaces can choose: snippets and notes from note_usage_events, rules from
|
||||||
rule_usage_events. Promoted here from SnippetListView's scoped block when
|
rule_usage_events. Shared here because a second scoped copy is how a
|
||||||
the rule list needed the same chip (milestone 333 step 5) — a second scoped
|
duplicated CSS family starts.
|
||||||
copy is how the ninth duplicated CSS family starts (#3207).
|
|
||||||
|
|
||||||
Geometry and colour only. A view keeps its own spacing as a scoped
|
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. */
|
remainder, the way it does for every other recipe in this file. */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* Moments (milestone 458): the pieces the rule editor's picker and the
|
/* Moments: the pieces the rule editor's picker and the
|
||||||
Settings section share, so a moment's name and a chip's remove control look
|
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 same wherever a person meets them. Loaded unscoped by both. */
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
milestone (tier, then verification) and were byte-identical; a third would
|
milestone (tier, then verification) and were byte-identical; a third would
|
||||||
have drifted. The pane's italic serif title is inherited by anything inside
|
have drifted. The pane's italic serif title is inherited by anything inside
|
||||||
it, so the chip resets family and style explicitly. */
|
it, so the chip resets family and style explicitly. */
|
||||||
/* A rule with no trigger cannot be retrieved, and since milestone 394
|
/* A rule with no trigger cannot be retrieved, and retrieval is the only
|
||||||
retrieval is the only delivery — so this marks a rule that will never
|
delivery — so this marks a rule that will never
|
||||||
reach a session. Warning rather than error: the rule is not broken, it is
|
reach a session. Warning rather than error: the rule is not broken, it is
|
||||||
unreachable, and the fix is one field away. */
|
unreachable, and the fix is one field away. */
|
||||||
.rule-chip-inert {
|
.rule-chip-inert {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* The sweep-row visual language (#3207).
|
/* The sweep-row visual language.
|
||||||
*
|
*
|
||||||
* A "sweep" is a pane that lists records in the order they most need a human
|
* 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
|
* — the note verification sweep, the rule sweep, preference drift — as one
|
||||||
|
|||||||
@@ -208,11 +208,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
The compatibility-alias block that lived here is GONE (#2533). It let ~55
|
Every component references the design system's --fs-* tokens directly.
|
||||||
components keep their historical --color-* names while theme.css was
|
Do not introduce app-local alias names — the design system's tokens are the
|
||||||
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
|
vocabulary, and check_snippets_against_design_system can only see through
|
||||||
names the system actually declares.
|
names the system actually declares.
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|||||||
@@ -132,11 +132,10 @@ router.afterEach(() => {
|
|||||||
}
|
}
|
||||||
/* Three tracks, not a flex row with an absolutely-centred overlay.
|
/* 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
|
* A pill bar at `position: absolute; left: 50%` does not participate in
|
||||||
* not participate in layout: when the header ran out of room it OVERLAPPED the
|
* layout: when the header runs out of room it OVERLAPS the brand and the
|
||||||
* brand and the utility cluster rather than pushing them, and nothing wrapped
|
* utility cluster rather than pushing them, and nothing wraps or scrolls to
|
||||||
* or scrolled to signal it. A sixth link reached that point at ~1270px, which
|
* signal it — at an ordinary window width on any monitor.
|
||||||
* is an ordinary window on any monitor.
|
|
||||||
*
|
*
|
||||||
* `1fr auto 1fr` fixes it structurally. A `1fr` track has an AUTO minimum, so
|
* `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
|
* neither side can be squeezed below its content, and the two side tracks stay
|
||||||
@@ -327,7 +326,7 @@ router.afterEach(() => {
|
|||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The grid above means running out of room can no longer cause a collision —
|
/* The grid above means running out of room cannot cause a collision —
|
||||||
but it can still make the header wider than the window, and a horizontally
|
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
|
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
|
wordmark goes first: the logo beside it says the same thing and is still the
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { fmtStamp } from "@/utils/dateFormat";
|
|||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scheduled backups (milestone 523): one dated, uncompressed tar per run,
|
* Scheduled backups: one dated, uncompressed tar per run,
|
||||||
* delivered to a folder or an S3 bucket. The page answers "is my data backed
|
* 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.
|
* up?" first — the verdict, then anything wrong — and the settings follow.
|
||||||
*/
|
*/
|
||||||
@@ -50,7 +50,7 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// While a backup runs, follow it. Re-armed from load()'s finally (rule 157),
|
// While a backup runs, follow it. Re-armed from load()'s finally, never after the work,
|
||||||
// so one failed read does not stop the page following the run.
|
// so one failed read does not stop the page following the run.
|
||||||
function schedulePoll() {
|
function schedulePoll() {
|
||||||
if (pollTimer) clearTimeout(pollTimer);
|
if (pollTimer) clearTimeout(pollTimer);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* The adoption matrix (milestone 463 step 4): every project × every canon
|
* The adoption matrix: every project × every canon
|
||||||
* family idea that reaches it, and the project's answer.
|
* family idea that reaches it, and the project's answer.
|
||||||
*
|
*
|
||||||
* Nobody approves an answer — the agent assesses each project against the
|
* Nobody approves an answer — the agent assesses each project against the
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* The inception form (milestone 297): "what does this project inherit?"
|
* The inception form: "what does this project inherit?"
|
||||||
*
|
*
|
||||||
* Two homes, one component. mode="create" rides the New-project modal's
|
* 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);
|
* second step and only emits the choices (the project does not exist yet);
|
||||||
* mode="decide" sits on ProjectView for an undecided project, loads that
|
* mode="decide" sits on ProjectView for an undecided project, loads that
|
||||||
* project's current defaults, and records the decision itself.
|
* project's current defaults, and records the decision itself.
|
||||||
*
|
*
|
||||||
* Platforms (milestone 463) start UNANSWERED (null). Ticking any box makes
|
* Platforms start UNANSWERED (null). Ticking any box makes
|
||||||
* the list the whole answer; in decide mode the boxes start from what
|
* the list the whole answer; in decide mode the boxes start from what
|
||||||
* detection already found, so recording confirms it.
|
* detection already found, so recording confirms it.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user