Compare commits

..
2 Commits
Author SHA1 Message Date
bvandeusen 93d660b710 Tool disambiguators, kind badges, and a badge layer that clears AA (#3123, #3124, #3132)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m17s
CI & Build / Build & push image (push) Successful in 15s
2026-08-27 21:28:48 -04:00
bvandeusen 056c7c75da A task's kind is correctable — the Kind select stops lying (#3129)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m6s
CI & Build / Build & push image (push) Successful in 15s
2026-08-27 18:05:16 -04:00
164 changed files with 1843 additions and 16532 deletions
+9 -61
View File
@@ -46,6 +46,8 @@ on:
- "alembic/**"
- "alembic.ini"
- "Dockerfile"
- "assets/**"
- "fable-mcp/**"
# The plugin ships straight from this repo — installs fetch it via
# .claude-plugin/marketplace.json, NOT from the image. So a push here is
# the release, with no build step in between. Omitting these paths meant
@@ -277,21 +279,6 @@ jobs:
env:
UV_PROJECT_ENVIRONMENT: /opt/venv
run: uv sync --locked --extra dev
# Standing answers to the checks carried by rules 81 and 79 — two facts
# about THIS runner that conditional rules assert as fact, and that
# otherwise need a throwaway job to confirm (#3237). Printing them on
# every integration run makes the next rulebook sweep a log read.
# Rule 80's evidence is the container listing the next step already
# prints. Every command is guarded: a diagnostic that can break the lane
# it observes is worse than no diagnostic.
- name: Runner facts (rules 79 and 81)
run: |
echo "--- rule 81: which shell runs a run: step ---"
readlink -f /bin/sh || echo "/bin/sh: not a symlink"
ps -p $$ -o comm= || true
echo "--- rule 79: is a service reachable by its hostname yet? ---"
getent hosts postgres \
|| echo "no — 'postgres' does not resolve; the bridge-IP lookup is still required"
- name: Integration suite (resolve service IP, migrate, test)
run: |
set -eux
@@ -302,9 +289,8 @@ jobs:
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP"
export DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test"
# Wait for Postgres to accept connections. The run: shell is dash
# (/bin/sh -> /usr/bin/dash on this Debian-based image, confirmed by
# the step above) — no bash /dev/tcp, so use Python.
# Wait for Postgres to accept connections (busybox sh — the runner
# default — has no bash /dev/tcp, so use Python).
/opt/venv/bin/python - "$PG_IP" <<'PY'
import socket, sys, time
for _ in range(30):
@@ -341,14 +327,6 @@ jobs:
packages: write
steps:
- uses: actions/checkout@v6
with:
# Rule 149 asks for this on any job deriving the version NAME. The
# name here comes from HEAD's commit TIME, which a depth-1 clone
# already has — but the rule states it unconditionally because the
# failure it guards is silent (a too-low value, every lane green),
# and a later change to how the name is derived would inherit the
# landmine rather than the guard.
fetch-depth: 0
- name: Generate image tags and version
id: tags
@@ -361,27 +339,7 @@ jobs:
# the runner log on commit 2a374d9.
run: |
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
# THREE VALUES, NEVER FOLDED TOGETHER (rule 149). Until 2026-08-31
# BUILD_VERSION was the CHANNEL — "dev" / "main" / the tag — so the
# image self-reported {"version":"main"}, a channel name where a
# build identifier belongs. That cost a debugging session: with the
# deploy misbehaving, nothing on the running instance could say
# which commit was serving it.
# 1. ORDERING KEY — BUILD time, monotonic by construction. Minutes
# since 2020-01-01. Never a commit count (not monotonic across
# branches) and never commit time (goes DOWN when an older
# commit is rebuilt).
BUILD_KEY=$(( ( $(date -u +%s) - 1577836800 ) / 60 ))
# 2. NAME — COMMIT time, so the same source reports the same string
# on every lane and the channel is the only thing that differs.
COMMIT_TS=$(git log --format=%ct -1 HEAD)
BUILD_NAME=$(date -u -d "@$COMMIT_TS" +%Y.%m.%d.%H%M)
# 3. CHANNEL — its own value. Never a suffix, never a segment.
CHANNEL="dev"
BUILD_VERSION="dev"
case "${{ github.ref }}" in
refs/heads/dev)
TAGS="$TAGS,${{ env.IMAGE }}:dev"
@@ -390,17 +348,15 @@ jobs:
# main IS the production line: publish :latest (plus the :<sha>
# set above). No separate :main tag.
TAGS="$TAGS,${{ env.IMAGE }}:latest"
CHANNEL="stable"
BUILD_VERSION="main"
;;
refs/tags/*)
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
CHANNEL="stable"
BUILD_VERSION="${{ github.ref_name }}"
;;
esac
echo "value=$TAGS" >> $GITHUB_OUTPUT
echo "build_name=$BUILD_NAME" >> $GITHUB_OUTPUT
echo "build_key=$BUILD_KEY" >> $GITHUB_OUTPUT
echo "channel=$CHANNEL" >> $GITHUB_OUTPUT
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
- name: Free disk space
# Self-hosted runner housekeeping. Two-step cleanup:
@@ -430,15 +386,7 @@ jobs:
push: true
provenance: false
tags: ${{ steps.tags.outputs.value }}
# All three, plus the commit — rule 145: the registry's identity for
# a build (:<sha>) and the artifact's identity for itself must
# agree, and they can only be checked against each other if the
# artifact says which commit it is.
build-args: |
BUILD_VERSION=${{ steps.tags.outputs.build_name }}
BUILD_KEY=${{ steps.tags.outputs.build_key }}
BUILD_CHANNEL=${{ steps.tags.outputs.channel }}
BUILD_COMMIT=${{ github.sha }}
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
# Registry-backed layer cache. Pull from :cache to prime
# BuildKit, push updated layers back to :cache so the next
# build starts warm even if the runner's local cache was
+2 -21
View File
@@ -41,29 +41,10 @@ COPY alembic/ alembic/
# Ensure Python finds the source tree (where static files live) before site-packages
ENV PYTHONPATH=/app/src
# THREE VALUES, NEVER FOLDED TOGETHER (rule 149), plus the commit.
#
# BUILD_VERSION is the NAME (YYYY.MM.DD.HHMM, from COMMIT time) — the same
# string on every lane for the same source, so it answers "is this the same
# code?" rather than "which lane built it?".
# BUILD_KEY is the ORDERING KEY (minutes since 2020-01-01, from BUILD time) —
# the only value anything may compare to decide what is newer.
# BUILD_CHANNEL is its own field. Never a suffix, never a segment of the name.
# BUILD_COMMIT lets the artifact's self-report be checked against the :<sha>
# it was published under (rule 145).
#
# Each defaults to empty rather than to a placeholder, EXCEPT the name: a
# local build genuinely has no ordering key or channel, and the endpoint says
# so by omitting them. Inventing values would make a local image claim a
# position in an update order it is not part of.
# Version is injected at build time via --build-arg BUILD_VERSION=YY.MM.DD.N
# Falls back to "dev" for local / untagged builds
ARG BUILD_VERSION=dev
ARG BUILD_KEY=
ARG BUILD_CHANNEL=
ARG BUILD_COMMIT=
ENV APP_VERSION=$BUILD_VERSION
ENV APP_BUILD_KEY=$BUILD_KEY
ENV APP_CHANNEL=$BUILD_CHANNEL
ENV APP_COMMIT=$BUILD_COMMIT
EXPOSE 5000
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
+1 -10
View File
@@ -1,4 +1,4 @@
.PHONY: build up down logs health migrate lint typecheck test fmt mint-plugin
.PHONY: build up down logs health migrate lint typecheck test fmt
# --- Docker ---
@@ -36,12 +36,3 @@ test:
# Run all checks in one shot (mirrors what CI does)
check: lint typecheck test
# --- Plugin ---
# Run this after changing anything under plugin/ or .claude-plugin/, BEFORE
# committing. The plugin ships straight from git with no build step, so its
# version is minted here rather than stamped by CI; the lane fails if you
# forget, but this is what makes remembering cheap.
mint-plugin:
python3 scripts/mint_plugin_version.py
@@ -1,80 +0,0 @@
"""a note can carry its own check — verify_with, expires_when, verified_at
(milestone 317 step 1)
Revision ID: 0092
Revises: 0091
Create Date: 2026-08-28
The sibling of 0090, which gave rules the same three columns. Same
distinction, one table over:
A NORM is a decision — no truth value, and it changes only when its author
changes it, which they know they did. A CONSTRAINT is a fact about someone
else's software, and nobody is present when it goes false.
Notes hold far more constraints than rules do, and hold them for longer. A
cross-project reference note asserting what a signing service does on a
duplicate upload, or how a forge numbers its CI runs, is believed by every
project that reads it, and there is nothing in the record that says when
anyone last looked. `note_supersessions` only fires once a human has read
the note, disagreed, and written the correction — which is the case where
the note was already believed.
Three nullable columns:
- `verify_with` — how to tell whether this is still true. A command, a path,
a URL, a query. Prose is allowed; something runnable is better.
- `expires_when` — the STATE under which it stops being true. Deliberately
not a date: constraints do not expire on a schedule, they expire when the
world underneath them moves.
- `verified_at` — when the check last passed. NULL means never checked, and
sorts FIRST in the sweep: unexamined outranks examined-long-ago.
WHICH ROWS THESE ARE FOR. `notes` is one table holding notes, tasks,
snippets and processes, so these columns land on all of them. Only non-task,
non-snippet records are OFFERED them (milestone 317 decisions 1 and 2, gated
at the service in step 2): a task's decay is its status, and a snippet
already carries a richer, location-aware verdict in `data.verification`. The
columns exist on the other rows and stay null there; a gate that lives in
the schema would have meant a partial index or a CHECK across three columns
to express something the write path can say in two lines.
All three optional, because most notes should set none of them — the whole
value of the sweep is that its output is short. A null `verify_with` is not
an omission; it is the honest marker of "this one is a decision, and there
is nothing to go and check."
No CHECK constraint is involved, so rule 36 does not apply. Nothing is
backfilled: a migration cannot invent a check.
"""
import sqlalchemy as sa
from alembic import op
revision = "0092"
down_revision = "0091"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("notes", sa.Column("verify_with", sa.Text(), nullable=True))
op.add_column("notes", sa.Column("expires_when", sa.Text(), nullable=True))
op.add_column(
"notes",
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
)
# No index, for 0090's reason — the sweep runs when a human asks, never on
# a request path — but the margin is thinner here and worth naming. `rules`
# is hundreds of rows; `notes` is thousands and grows with every session.
#
# Still a sequential scan's job at this size, and an index on
# (verified_at) filtered to `verify_with IS NOT NULL` would be maintained
# on every note write to serve one operator-initiated query. If step 3's
# live acceptance measures otherwise, add it there against a real plan
# rather than guessing here.
def downgrade() -> None:
op.drop_column("notes", "verified_at")
op.drop_column("notes", "expires_when")
op.drop_column("notes", "verify_with")
-83
View File
@@ -1,83 +0,0 @@
"""rules gain an edit history — rule_versions (milestone 323 step 1)
Revision ID: 0093
Revises: 0092
Create Date: 2026-08-29
The sibling `note_versions` has had for a long time. A note's every meaningful
edit is snapshotted, and the design-system note calls that history "the
changelog". A RULE — which binds behaviour on every session that loads it —
had nothing: an edit destroyed what it used to say, with no record anywhere.
Rescoping rule 79 on 2026-08-29 is what surfaced it. The superseded statement
had to be hand-copied into a task log to survive the edit (#3237), which is
not a process, it is a person remembering. The more consequential record had
the weaker protection.
Three things are deliberately NOT copied from note_versions, and each is a
guard that exists there for a reason that does not hold here:
- **No pruning, and no MAX_VERSIONS.** That cap defends against note autosave
filling every slot. Rules have no autosave; every edit is a deliberate
update_rule. A rule is edited a handful of times in its life, and capping
invites losing the one edit somebody needed.
- **No pin columns.** `pin_kind`/`pin_label` exist so a note's version can
survive that pruning. With nothing pruning, a pin protects a row that was
never at risk.
- **No minimum interval.** 300 seconds between snapshots is also an autosave
defence; here it would only ever discard a second deliberate edit.
`user_id` is the ACTOR rather than the owner, and is SET NULL rather than
CASCADE: deleting a user must not erase the history of the rules they edited.
The edit still happened and the rule still binds because of it.
No CHECK constraint, so rule 36 does not apply. Nothing is backfilled — a
migration cannot invent the text a rule used to have, and inventing "the
current text, as of now" would be worse than an empty history, because it
would look like a record of an edit that never occurred.
"""
import sqlalchemy as sa
from alembic import op
revision = "0093"
down_revision = "0092"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"rule_versions",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column(
"rule_id",
sa.BigInteger(),
sa.ForeignKey("rules.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
sa.BigInteger(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("title", sa.Text(), nullable=False, server_default=""),
sa.Column("statement", sa.Text(), nullable=False, server_default=""),
sa.Column("why", sa.Text(), nullable=True),
sa.Column("how_to_apply", sa.Text(), nullable=True),
sa.Column("when_to_apply", sa.Text(), nullable=True),
sa.Column("tier", sa.Text(), nullable=True),
sa.Column("verify_with", sa.Text(), nullable=True),
sa.Column("expires_when", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
# The only query this table serves is "the history of THIS rule, newest
# first" — unlike 0092's columns, which are read by an operator-initiated
# sweep over the whole set. Every read here is keyed on rule_id, so the
# index earns its write cost immediately rather than on a hunch.
op.create_index("ix_rule_versions_rule_id", "rule_versions", ["rule_id"])
def downgrade() -> None:
op.drop_index("ix_rule_versions_rule_id", table_name="rule_versions")
op.drop_table("rule_versions")
@@ -1,86 +0,0 @@
"""add rule_usage_events — was a surfaced rule ever read? (milestone 333 step 1)
Revision ID: 0094
Revises: 0093
Create Date: 2026-09-02
The sibling `note_usage_events` has had since 0071, and the third rule-side
table to arrive after `rule_embeddings` and `rule_versions` — each one added
because the rule side kept inheriting machinery built for notes and getting
the weaker version of it.
WHAT IT MEASURES. The write-path standing-rule arm is the only retrieval
surface in Scribe whose usefulness cannot be observed, and — not coincidentally
— the only one that has never declined to fire. Over 30 days it took 296 calls,
returned something on every one, and cleared its threshold 100% of the time,
while every other surface declines most of the time (#3311). That is either a
perfectly tuned surface or a bar it cannot fail to clear, and `retrieval_logs`
cannot tell them apart: it records what the ranker scored, never whether the
hint was any use.
WHY NOT A rule_id COLUMN ON note_usage_events. The row shares no note-specific
fields and the aggregate readout is the same shape, which is the strongest case
for sharing that note #3163 admits. What decides against it is identity at
RESTORE: `note_usage_events`'s importer maps `note_id` through `note_id_map`
and drops what does not resolve. A rule id parked in that column would come
back from a backup silently reattached to whatever note took that number —
telemetry not merely lost but wrong, and wrong in a way nothing downstream
could detect. `rule_versions` made the same call for the same reason.
FK-free on `rule_id` and `user_id`, matching note_usage_events, retrieval_logs
and app_logs — and deliberately unlike `rule_versions`, which does carry FKs.
The difference is what the row is for: a version belongs to a rule's history
and dies with it; telemetry outlives the row it describes. Deleting a rule must
not erase the evidence that it was surfaced forty times and opened never, since
that evidence is exactly the case for having deleted it.
No CHECK on `event`, matching the note twin. Rule 36 governs adding a value to
a column that is already gated; it does not require gating one that never was,
and a two-member enum whose members are written by two functions in one module
is not where that discipline earns its cost.
Downgrade drops the table outright. The data is purely observational — nothing
reads it for correctness, so losing it costs history and no behaviour.
"""
from alembic import op
import sqlalchemy as sa
revision = "0094"
down_revision = "0093"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"rule_usage_events",
# BigInteger throughout where the note twin uses Integer: rules.id is
# BigInteger, so rule_id must be, and a high-churn append-only table is
# a poor place to discover an id ceiling.
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column("user_id", sa.BigInteger(), nullable=True),
sa.Column("rule_id", sa.BigInteger(), nullable=False),
sa.Column("event", sa.Text(), nullable=False),
sa.Column("source", sa.Text(), nullable=False),
)
# Every readout is "these rule ids, split by event", so the composite is the
# one that actually gets used; the others serve pruning and per-user views.
op.create_index(
"ix_rule_usage_rule_event", "rule_usage_events", ["rule_id", "event"]
)
op.create_index("ix_rule_usage_created_at", "rule_usage_events", ["created_at"])
op.create_index("ix_rule_usage_user_id", "rule_usage_events", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_rule_usage_user_id", table_name="rule_usage_events")
op.drop_index("ix_rule_usage_created_at", table_name="rule_usage_events")
op.drop_index("ix_rule_usage_rule_event", table_name="rule_usage_events")
op.drop_table("rule_usage_events")
@@ -1,52 +0,0 @@
"""add retrieval_logs.suppressed_count — tell a ranker decline from a repeat (#3497)
Revision ID: 0095
Revises: 0094
Create Date: 2026-09-03
`result_count == 0` has always meant "this surface said nothing", which is the
right number for "was the hint any use" and the wrong one for tuning a
threshold. It folds together two unrelated events:
- the ranker found nothing above the bar — the ONLY evidence a threshold is
set too high; and
- the ranker found something the session had already been shown — a decline
that says nothing whatever about the bar.
The rule arms filter in Python after the search, so they can count the second
kind exactly. The note arms pass `exclude_ids` INTO semantic_search_notes, so
the dropped rows never come back and there is nothing to count.
NULLABLE, AND THE NULL IS THE POINT. A surface that does not measure
suppression stores NULL, not 0, and the readout renders it as "not measured"
rather than "none". Defaulting to 0 would make an unmeasured surface look like
a perfectly clean one — the exact substitution of an artifact for a
measurement that #3311 made and that #3497 exists to correct. Doing it again,
in the migration that fixes it, would be its own small joke.
No backfill for the same reason: existing rows genuinely do not know, and
saying so is the honest state. `retrieval_logs` is not restored from backup,
so no importer changes.
Downgrade drops the column. Purely observational — nothing reads it for
correctness.
"""
from alembic import op
import sqlalchemy as sa
revision = "0095"
down_revision = "0094"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"retrieval_logs",
sa.Column("suppressed_count", sa.Integer(), nullable=True),
)
def downgrade() -> None:
op.drop_column("retrieval_logs", "suppressed_count")
@@ -1,62 +0,0 @@
"""add retrieval_logs.best_available_score — the score the bar rejected (#3670)
Revision ID: 0096
Revises: 0095
Create Date: 2026-09-08
`cleared_threshold` was documented as the number to read FIRST — "a surface
that clears its bar on nearly every call is either well-tuned or too loose,
and p10 says which". It was never a measurement. The search applies the
threshold before returning, so every returned result cleared the bar by
construction and a call with no results has no `top_score` to compare:
the condition is true exactly when `result_count > 0`.
`zero_result_calls + cleared_threshold == calls` held on all nineteen
source/window readings ever taken. It was `calls - zero_result_calls`
wearing a name that promised a second opinion, and a reading procedure was
built on top of it that asked the reader to compare a number against itself.
THE MISSING NUMBER, and the reason this is a column rather than a deletion.
The question the table exists to answer is "is the bar in the right place",
and that question is only answerable from the calls that returned NOTHING:
how close did the best rejected candidate come? A bar at 0.72 turning away
a stream of 0.71s is set too high by a hair. A bar turning away 0.30s is
doing its job. Those two are indistinguishable today — both render as a
zero-result call — and no arrangement of the existing columns separates
them, because the losing score is discarded inside the search.
So the searches now rank without the bar and apply it in Python, which
costs nothing (the rows were already ordered by distance, and the qualifying
set is provably identical — above-threshold rows sort first), and the best
score seen becomes observable.
NULLABLE, AND UNBACKFILLED, for the reason 0095 spells out: a row written
before this shipped genuinely does not know what its best rejected candidate
scored, and saying so is the honest state. A 0.0 default would read as "the
corpus had nothing remotely relevant" — an artifact standing in for a
measurement, which is the whole defect this milestone corrects.
`retrieval_logs` is not restored from backup, so no importer changes.
Downgrade drops the column. Purely observational — nothing reads it for
correctness.
"""
from alembic import op
import sqlalchemy as sa
revision = "0096"
down_revision = "0095"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"retrieval_logs",
sa.Column("best_available_score", sa.Float(), nullable=True),
)
def downgrade() -> None:
op.drop_column("retrieval_logs", "best_available_score")
@@ -1,60 +0,0 @@
"""add retrieval_logs.best_available_id — WHICH record the bar refused (#3807)
Revision ID: 0097
Revises: 0096
Create Date: 2026-09-09
0096 added `best_available_score` so a threshold could be judged from what it
rejected. It records how CLOSE the bar came to firing and not WHAT it turned
away, and that turns out to be the half a decision actually needs.
Live, `pre_tool_rule` shows a bar of ~0.72 with a near-miss p90 of 0.7071 —
about 117 declines a day sitting within 0.013 of firing. Dropping the bar to
0.707 would take that arm from 22 hits a day to roughly 139, a six-fold change
on a surface that runs before every Bash call. The percentile says the mass is
there. Nothing says whether it is worth showing.
AND THE TWO OBVIOUS INSTRUMENTS DO NOT ANSWER IT. Pull-through cannot: the
injected rule line already carries title and trigger, so a session can comply
without ever calling `get_rule`, and rule pull-through therefore understates
usefulness by construction. Reading the rejected records can — and `result_ids`
holds only what was RETURNED, so on a zero-result call it is empty and the
near-missed record has no name.
So: the id, beside the score, from the SAME ranked candidate. The two must
never be able to describe different records — a score paired with its
neighbour's id would be worse than no id at all, because it invites a reader to
judge the wrong record and conclude the bar is fine.
NULLABLE AND UNBACKFILLED, for the reason 0095 and 0096 both spell out: a row
written before this genuinely does not know, and inventing a value would put an
artifact where a measurement belongs. Null here means "not measured", never
"nothing was close".
NOT A FOREIGN KEY, deliberately. `retrieval_logs` spans record types — the
rules arms store rule ids, the note arms store note ids — and `source` is what
says which table an id belongs to, exactly as `result_ids` has always worked.
A constraint would have to point at one table and would be wrong for the other.
Downgrade drops the column. Purely observational — nothing reads it for
correctness.
"""
from alembic import op
import sqlalchemy as sa
revision = "0097"
down_revision = "0096"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"retrieval_logs",
sa.Column("best_available_id", sa.Integer(), nullable=True),
)
def downgrade() -> None:
op.drop_column("retrieval_logs", "best_available_id")
-90
View File
@@ -1,90 +0,0 @@
"""rules gain a `kind` — a preference is a rule that does not bind (#3849)
Revision ID: 0098
Revises: 0097
Create Date: 2026-09-10
A rule says what must be followed. There was no way to record the other
thing the operator kept writing rules for: **how they want work done**.
Several rules in a mature rulebook are not really rules — pace this kind of
debugging, hand off an action with its reason, end a finding with an offer.
Ignoring one of those does not break anything or cross a boundary; it costs
consistency. They were written as rules because a rule was the only record
that is global, keyed to a situation, and delivered when that situation
arrives.
So: `kind`. `rule` binds. `preference` describes how this person wants it
done, and — the part that makes it its own kind rather than a softer label —
**it is expected to change as the work teaches it.** The agent updates a
preference in the ordinary course of working, where a rule waits for its
author.
WHY A COLUMN AND NOT A TABLE. Everything a preference needs already exists on
`rules` and nowhere else: `when_to_apply` as a real column, a
trigger-dominated embedding document, ownership-scoped search that is
deliberately not filtered to one project, three retrieval arms with per-arm
telemetry, typed relations, and versioning. The two differ in exactly one
dimension — force — and one dimension is a field.
The drift machinery is the decisive part. `rule_versions` already snapshots
every write, and `rule_relations.overrides` already models "this supersedes
that for its scope". A separate table would have rebuilt both, and moving
existing rows into it would have changed their ids — silently invalidating
every record in the corpus that cites a rule by number.
DEFAULTS TO `rule`, so this migration changes NOTHING about what binds. An
install upgrades and every existing row keeps the force it had. That is the
same reasoning 0088 used for `tier`, and it is the reason both are safe to
apply without reading the data first.
The CHECK is created with the column (rule 36: there is no prior constraint,
so the pair is created together — a value added to it LATER does DROP + ADD
in one migration).
`rule_versions` GETS THE COLUMN TOO, and that half is not bookkeeping.
`record_if_changed` decides whether an edit is worth a snapshot by comparing
the fields a version carries; a field a version does not carry is a field
whose change records NO HISTORY AT ALL. Without this, turning a rule into a
preference — the single most consequential edit either kind can undergo,
because it is the moment something stops binding — would leave the history
silent. Nullable and no CHECK there, matching `tier`: a version is a record
of what was, and a constraint on it would refuse to store a kind later
dropped from the live whitelist.
Downgrade drops all three. Nothing reads `kind` for correctness; a rule that
was a preference simply becomes a rule again, which is the safe direction.
"""
import sqlalchemy as sa
from alembic import op
revision = "0098"
down_revision = "0097"
branch_labels = None
depends_on = None
# Kept in one place so upgrade and the CHECK agree by construction — 0088's
# idiom, for the same reason.
_KINDS = ("rule", "preference")
def _in_list(column: str, values: tuple[str, ...]) -> str:
return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")"
def upgrade() -> None:
op.add_column(
"rules",
sa.Column("kind", sa.Text(), nullable=False, server_default="rule"),
)
op.create_check_constraint("ck_rules_kind", "rules", _in_list("kind", _KINDS))
# Nullable, no CHECK — see the module docstring. A version written before
# this migration genuinely does not know, and NULL there means "not
# recorded", never "was a rule".
op.add_column("rule_versions", sa.Column("kind", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("rule_versions", "kind")
op.drop_constraint("ck_rules_kind", "rules", type_="check")
op.drop_column("rules", "kind")
@@ -1,104 +0,0 @@
"""scrub credential-shaped spans out of retrieval_logs.query
Revision ID: 0099
Revises: 0098
Create Date: 2026-09-11
`pre_tool_rule` retrieves against the RAW COMMAND TEXT and `write_path_rule`
against the code being written, so whatever was on the command line or in the
buffer is what `record_retrieval` wrote into `retrieval_logs.query`. A command
that exported a token stored the token (#3925).
`services/retrieval_telemetry.scrub_secrets` closes that going forward — the
value never reaches the column. It cannot reach BACKWARDS, and this does: it
rewrites the rows already written.
REDACTED IN PLACE, NOT DELETED. The rest of the row — score, threshold,
result count, duration, the near-miss record id — is legitimate evidence, and
it is what a threshold is tuned from. Deleting the row would throw that away
to remove a secret that lives in one column, so the column is what gets
rewritten. Rows with no credential in them are not touched at all.
THE PATTERNS ARE INLINED RATHER THAN IMPORTED, deliberately, against the DRY
instinct. A migration is a frozen record of a change that already happened on
every install that ran it; importing the live patterns would mean this
migration quietly does something different next year than it did when it ran,
and two installs at the same revision would no longer be in the same state.
The Python twin in `services/retrieval_telemetry.py` is free to grow — this is
what ran here, once. The one thing that must not drift is coverage, and the
guard for that is `test_retrieval_query_scrubbing.py`, which tests the live
function rather than this copy.
POSIX regex, not Python's. Postgres ARE supports the non-greedy `*?` the PEM
pattern needs, and `\\s`/`\\S`, so the shapes port directly. The `'gi'` flags
are global + case-insensitive, matching `re.sub` with `(?i)`.
NO BARE `auth` IN THE ASSIGNED PATTERN. It matches `--author=`, so a commit
naming an address would have had the address redacted — evidence eaten for a
word that only looks credential-shaped. `AUTH_TOKEN` is still caught, by
`token`.
Downgrade is a no-op, and honestly so: the original text is gone and a
migration cannot invent it back. Saying that plainly is better than a
downgrade that appears to restore something and does not.
"""
from alembic import op
revision = "0099"
down_revision = "0098"
branch_labels = None
depends_on = None
# Vendor-prefixed credentials — the prefix IS the tell, so no entropy guessing.
_TOKEN = (
r"(fmcp_|flt_|ghp_|gho_|ghs_|ghu_|github_pat_|glpat-|xox[abprs]-"
r"|sk-[A-Za-z0-9]*-?|AKIA|ASIA)[A-Za-z0-9_\-]{12,}"
)
# A value handed to a secret-NAMED variable, in shell, env, YAML, JSON or a
# query string. The NAME identifies it, so the value can be anything.
_ASSIGNED = (
r"([A-Za-z0-9_]*(token|secret|password|passwd|api[_-]?key|access[_-]?key)"
r"[A-Za-z0-9_]*)(\s*[:=]\s*[\"']?)([^\s\"'&]{8,})"
)
_AUTH_HEADER = r"(authorization\s*:\s*(bearer|basic|token)\s+)(\S+)"
_PEM = (
r"-----BEGIN [A-Z ]*PRIVATE KEY-----(.|\n)*?-----END [A-Z ]*PRIVATE KEY-----"
)
def _lit(pattern: str) -> str:
"""A regex as a SQL string literal.
A single quote inside a single-quoted SQL literal has to be DOUBLED, and
the assigned-value pattern contains two of them (it allows an optional
quote around the value). Left unescaped they close the literal early and
the migration dies on a syntax error — which is the whole reason this
helper exists rather than the patterns being pasted in inline.
"""
return pattern.replace("'", "''")
_SCRUB_SQL = f"""
UPDATE retrieval_logs
SET query = regexp_replace(
regexp_replace(
regexp_replace(
regexp_replace(query, '{_lit(_TOKEN)}', '[redacted:token]', 'gi'),
'{_lit(_ASSIGNED)}', '\\1\\3[redacted:assigned]', 'gi'),
'{_lit(_AUTH_HEADER)}', '\\1[redacted:auth-header]', 'gi'),
'{_lit(_PEM)}', '[redacted:private-key]', 'gi')
WHERE query IS NOT NULL
AND (query ~* '{_lit(_TOKEN)}'
OR query ~* '{_lit(_ASSIGNED)}'
OR query ~* '{_lit(_AUTH_HEADER)}'
OR query ~* '{_lit(_PEM)}')
"""
def upgrade() -> None:
op.execute(_SCRUB_SQL)
def downgrade() -> None:
"""Deliberately empty — the original text no longer exists to restore."""
@@ -1,96 +0,0 @@
"""drop the always-on tier: rules.tier, rulebooks.always_on, the exclusions table
Revision ID: 0100
Revises: 0099
Create Date: 2026-09-11
Milestone 394. Every rule now reaches a session by retrieval — because
something it is about to do made the rule relevant — and the machinery that
delivered rules unconditionally goes with it.
WHAT GOES, AND WHERE IT CAME FROM
- ``rules.tier`` and its ``ck_rules_tier`` CHECK (migration 0088). Dropping
the column takes the constraint with it. Rule 36 is about ADDING a value to
a live whitelist, which needs DROP + ADD in the same migration; it does not
speak to removing the column outright, and saying so here is cheaper than
the next reader wondering whether it was forgotten.
- ``rule_versions.tier`` (migration 0098). A version records what a rule
SAID; with no tier on a rule there is nothing for a snapshot to carry.
- ``rulebooks.always_on`` (migration 0058). A rulebook reaches a project by
subscription now, and by nothing else.
- ``project_rulebook_exclusions`` (migration 0085). It recorded a project's
opt-out of an always-on rulebook. Opting out of something that no longer
binds you is not a state that can exist — declining a rulebook is
expressed by not subscribing to it.
IRREVERSIBLE, AND THE DOWNGRADE SAYS SO RATHER THAN PRETENDING
The downgrade recreates the columns and the table with their DEFAULTS. It
cannot restore WHICH rules were always-on, which rulebooks bound every project,
or which projects had opted out — that information is in what this drops.
That distinction is the one this repo keeps insisting on: a value invented to
fill a hole is not a measurement. So a downgraded database is structurally able
to run the old code and is NOT the database the old code was running against —
every rule comes back at the ``always_on`` default, which for the tier column
happens to mean "binding", the safe direction to be wrong in.
Anyone who needs the real prior state restores a backup taken before this ran.
"""
import sqlalchemy as sa
from alembic import op
revision = "0100"
down_revision = "0099"
branch_labels = None
depends_on = None
_TIERS = ("always_on", "conditional")
def _in_list(column: str, values: tuple[str, ...]) -> str:
return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")"
def upgrade() -> None:
op.drop_table("project_rulebook_exclusions")
op.drop_column("rulebooks", "always_on")
op.drop_column("rule_versions", "tier")
# The CHECK goes with the column it constrains; naming it here would be a
# second drop of the same object.
op.drop_column("rules", "tier")
def downgrade() -> None:
"""Structure only. See the module docstring — the values are gone."""
op.add_column(
"rules",
sa.Column("tier", sa.Text(), nullable=False, server_default="always_on"),
)
op.create_check_constraint("ck_rules_tier", "rules", _in_list("tier", _TIERS))
op.add_column("rule_versions", sa.Column("tier", sa.Text(), nullable=True))
op.add_column(
"rulebooks",
sa.Column(
"always_on", sa.Boolean(), nullable=False,
server_default=sa.text("false"),
),
)
op.create_table(
"project_rulebook_exclusions",
sa.Column(
"project_id", sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
primary_key=True, nullable=False,
),
sa.Column(
"rulebook_id", sa.BigInteger(),
sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
primary_key=True, nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True),
server_default=sa.text("now()"), nullable=True,
),
)
+1 -1
View File
@@ -89,7 +89,7 @@ table here. The tools are grouped by family:
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
| Search / Recall | `search`, `get_recent`, `list_tags`, `retrieval_telemetry` | Semantic + structured recall, and the readout its thresholds are tuned from |
| Systems | `create_system`, `list_systems`, `list_system_records` | Reusable per-project subsystems/areas |
| Rulebooks | `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules |
| Rulebooks | `list_always_on_rules`, `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules |
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
| Admin | `get_app_logs` (write/admin key) | Diagnostics |
+3 -2
View File
@@ -77,7 +77,7 @@ endpoint at `/mcp`, not these REST routes.
|--------|------|-------------|
| GET / POST | `/api/projects` | List (owned + shared) / create |
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`, `inception`) / update / delete |
| POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {subscribe_rulebooks, design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) |
| POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) |
| GET | `/api/projects/:id/inception/defaults` | What binds if nobody decides — the inception card's payload |
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
@@ -120,6 +120,7 @@ endpoint at `/mcp`, not these REST routes.
| POST | `/api/projects/:id/rules` | Create a project-scoped rule |
| POST / DELETE | `/api/projects/:id/suppressions/rules/:rid` | Suppress / unsuppress a rule |
| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic |
| POST / DELETE | `/api/projects/:id/exclusions/rulebooks/:rid` | Exclude / include an always-on rulebook for this project (inception) |
## Sharing
@@ -205,6 +206,6 @@ endpoint at `/mcp`, not these REST routes.
Claude clients connect to the built-in MCP server at `POST /mcp` (streamable HTTP,
Bearer auth with an `fmcp_` key), served by `src/scribe/mcp/`. It is not a REST
surface — it exposes the same data as typed tools (`create_note`, `create_task`,
`start_planning`, `search`, `enter_project`, …) with
`start_planning`, `search`, `enter_project`, `list_always_on_rules`, …) with
server-level usage guidance delivered in the MCP `instructions` block. See
[API Keys & MCP](api-keys-and-mcp.md).
+3 -5
View File
@@ -60,10 +60,8 @@ Scribe stores the operator's engineering and workflow **rules** so Claude follow
across sessions.
- **Rulebooks → topics → rules** — Rules are grouped by topic inside a rulebook.
- **Rules arrive by retrieval** — Nothing is preloaded. A rule reaches a
session when what the agent is about to do matches its trigger: a command,
a file being written, or the operator's own message. `when_to_apply` is
therefore the field that decides whether a rule is ever seen.
- **Always-on rules** — A rulebook can be flagged always-on; its rules load at the
start of every session through the plugin's push channel.
- **Per-project scope** — A project subscribes to rulebooks, and can add
project-scoped rules or suppress individual inherited rules/topics.
@@ -96,7 +94,7 @@ The whole store is reachable by Claude through a built-in **MCP endpoint at `/mc
(Bearer-auth with an API key). The **Scribe Claude Code plugin** (shipped in this
repo) wires it up:
- a `SessionStart` hook that injects active-project
- a `SessionStart` hook that injects the operator's always-on rules + active-project
context so Scribe surfaces without being asked (fail-open if Scribe is unreachable);
- universal process-skills — writing-plans, systematic-debugging, verification,
brainstorming — that route their output into Scribe;
+6 -19
View File
@@ -7,20 +7,12 @@ import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
import { useSettingsStore } from "@/stores/settings";
import { apiPut } from "@/api/client";
import { fetchVersion } from "@/api/version";
import { apiGet, apiPut } from "@/api/client";
useTheme();
const router = useRouter();
// THREE states, not two (#3127 checklist 12). `null` is "not answered yet" and
// renders nothing; a string renders; `appVersionFailed` renders its own thing.
// This used to default to the literal "dev" and swallow the error, which meant
// an instance that could not answer was indistinguishable from a local build
// that genuinely reports "dev" — a blank standing in for `unknown`, in the one
// readout whose whole job is to say what is running.
const appVersion = ref<string | null>(null);
const appVersionFailed = ref(false);
const appVersion = ref("dev");
const authStore = useAuthStore();
const settingsStore = useSettingsStore();
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
@@ -127,12 +119,10 @@ onMounted(async () => {
startAppServices();
}
try {
appVersion.value = (await fetchVersion()).version;
const data = await apiGet<{ version: string }>("/api/version");
appVersion.value = data.version;
} catch {
// Not silent any more: the footer says it could not find out, rather than
// showing a version it never received. The full readout (version, channel,
// commit, build) lives in Settings → Config.
appVersionFailed.value = true;
// silent — version display is non-critical
}
});
@@ -161,10 +151,7 @@ onUnmounted(() => {
<div id="main-content" class="app-content">
<router-view />
</div>
<footer class="app-footer">
<span v-if="appVersion">v{{ appVersion }}</span>
<span v-else-if="appVersionFailed">version unknown</span>
</footer>
<footer class="app-footer">v{{ appVersion }}</footer>
</div>
<!-- Keyboard shortcuts overlay -->
+29 -124
View File
@@ -52,121 +52,41 @@ export function apiErrorMessage(e: unknown, fallback: string): string {
return fallback;
}
/**
* How long an ordinary JSON call may wait before it is declared failed.
*
* Rule 156: a wait with no deadline is a bug. `fetch`'s own default is to wait
* as long as the browser will, which is not a deadline — it is the absence of
* one, and it renders as a spinner that never resolves. There is no state a
* surface can show for "pending forever" that is not a lie.
*
* 30s is chosen to be longer than anything healthy: it has to clear a cold
* embedding call and a list view under connection-pool contention (#2384 had
* /api/projects fanning 25 concurrent sessions at a 15-connection pool), so
* tripping it means something is genuinely wrong rather than merely busy. Slow
* BY DESIGN is a different case and passes its own value — see the callers in
* SettingsView that do.
*/
const DEFAULT_TIMEOUT_MS = 30_000;
/** HTTP 408. Not a status any Scribe route returns, so it unambiguously means
* "the client gave up" rather than anything the server said. */
const CLIENT_TIMEOUT_STATUS = 408;
/**
* How long a STREAM may take to answer with its headers.
*
* Streams are the one case a wall-clock deadline would break: a long-lived SSE
* connection is *supposed* to stay open, and `AbortSignal.timeout` would kill
* it mid-flight along with the body. But that does not exempt them from rule
* 156 — it relocates the deadline. Two different waits are involved:
*
* connect — the server answering with headers. CAN fail to answer, so it
* carries this deadline, cleared the moment headers arrive.
* stream — the body, open indefinitely on purpose. Its failure mode is
* going quiet, which a timeout cannot tell from being idle; that
* is what reconnection and Last-Event-ID are for, not this.
*
* Reading the connect as exempt because "the stream is long-lived" is the easy
* mistake here, and it leaves an unreachable server looking like a quiet one.
*/
const STREAM_CONNECT_TIMEOUT_MS = 15_000;
/**
* A signal that aborts if headers do not arrive in time, plus the `settle` to
* call once they do. After `settle()` the returned signal never fires, so the
* stream body runs unbounded — which is the intent.
*/
function connectDeadline(base: AbortSignal): { signal: AbortSignal; settle: () => void } {
const gate = new AbortController();
const timer = setTimeout(
() => gate.abort(new DOMException("stream did not connect in time", "TimeoutError")),
STREAM_CONNECT_TIMEOUT_MS,
);
return {
signal: AbortSignal.any([base, gate.signal]),
settle: () => clearTimeout(timer),
};
}
export interface RequestOpts {
/** Override the deadline. Pass one when the call is slow BY DESIGN. */
timeoutMs?: number;
}
/**
* The one place a request is actually made — every verb below goes through
* here, so the deadline cannot be forgotten by adding a sixth.
*
* EXPIRY SURFACES AS AN `ApiError`, which is rule 156's second half: the
* failure has to arrive in the shape the caller already handles. A bare
* `DOMException: TimeoutError` would reach `apiErrorMessage(e, fallback)` as
* an object with no `body`, so every catch site in the app would report its
* generic fallback and the timeout would be invisible in the very situation it
* exists to expose. Rethrowing as `ApiError` means ~330 existing call sites
* report it correctly without being touched.
*
* Only a TIMEOUT is converted. A deliberate cancellation aborts with
* `AbortError` and is left alone — a caller that cancelled its own request
* does not want it reported as a server failure.
*/
async function request<T>(path: string, init: RequestInit, opts?: RequestOpts): Promise<T> {
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
let res: Response;
try {
res = await fetch(path, { ...init, signal: AbortSignal.timeout(timeoutMs) });
} catch (e) {
if (e instanceof DOMException && e.name === "TimeoutError") {
throw new ApiError(CLIENT_TIMEOUT_STATUS, {
error: `The server did not answer within ${Math.round(timeoutMs / 1000)}s.`,
});
}
throw e;
}
export async function apiGet<T>(path: string): Promise<T> {
const res = await fetch(path);
return handleResponse<T>(res, path);
}
/** JSON body headers — the three write verbs sent an identical literal each. */
const JSON_HEADERS = { "Content-Type": "application/json" };
export function apiGet<T>(path: string, opts?: RequestOpts): Promise<T> {
return request<T>(path, {}, opts);
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return handleResponse<T>(res, path);
}
export function apiPost<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
return request<T>(path, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return handleResponse<T>(res, path);
}
export function apiPut<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
return request<T>(path, { method: "PUT", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return handleResponse<T>(res, path);
}
export function apiPatch<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
return request<T>(path, { method: "PATCH", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
}
export function apiDelete(path: string, opts?: RequestOpts): Promise<void> {
return request<void>(path, { method: "DELETE" }, opts);
export async function apiDelete(path: string): Promise<void> {
const res = await fetch(path, { method: "DELETE" });
return handleResponse<void>(res, path);
}
// ---------------------------------------------------------------------------
@@ -301,14 +221,7 @@ export function apiSSEStream(
}
const done = (async () => {
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
const connect = connectDeadline(combinedSignal);
let res: Response;
try {
res = await fetch(path, { headers, signal: connect.signal });
} finally {
connect.settle();
}
const res = await fetch(path, { headers, signal: combinedSignal });
if (!res.ok) {
let body: Record<string, unknown> = {};
try {
@@ -405,19 +318,11 @@ export async function apiStreamPost(
body: unknown,
onChunk: (data: Record<string, unknown>) => void
): Promise<void> {
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
const connect = connectDeadline(new AbortController().signal);
let res: Response;
try {
res = await fetch(path, {
const res = await fetch(path, {
method: "POST",
headers: JSON_HEADERS,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: connect.signal,
});
} finally {
connect.settle();
}
if (!res.ok) {
let errBody: Record<string, unknown> = {};
try {
+5 -2
View File
@@ -2,6 +2,7 @@
import { apiGet, apiPost } from "@/api/client";
export interface InceptionChoices {
exclude_always_on_rulebooks: number[];
subscribe_rulebooks: number[];
design_system_id: number | null;
seed_systems: boolean;
@@ -15,7 +16,9 @@ export interface InceptionRecord {
}
export interface InceptionDefaults {
rulebooks: { id: number; title: string }[];
always_on_rulebooks: { id: number; title: string }[];
other_rulebooks: { id: number; title: string }[];
excluded_always_on: { id: number; title: string }[];
subscribed_rulebooks: { id: number; title: string }[];
design_system_id: number | null;
design_systems: { id: number; title: string }[];
@@ -29,7 +32,7 @@ export interface InceptionDecision {
}
export const emptyChoices = (): InceptionChoices => ({
subscribe_rulebooks: [], design_system_id: null, seed_systems: false,
exclude_always_on_rulebooks: [], subscribe_rulebooks: [], design_system_id: null, seed_systems: false,
});
export const fetchInceptionDefaults = (projectId: number) =>
+25 -52
View File
@@ -1,8 +1,7 @@
import type { RecordUsage } from "@/types/usage";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
/** How a rule reaches a session (milestone 307). */
export type RuleTier = "always_on" | "conditional";
/**
* A typed edge between two rules. Each kind exists because its absence forced
@@ -25,6 +24,7 @@ export interface Rulebook {
owner_user_id: number;
title: string;
description: string;
always_on: boolean;
created_at: string | null;
updated_at: string | null;
}
@@ -47,6 +47,12 @@ export interface Rule {
statement: string;
/** WHEN this rule fires — the trigger, not the instruction. */
when_to_apply: string;
/**
* always_on preloads into every session; conditional is reachable and
* surfaced when its trigger fires. A rule with no tier set behaves as
* always_on, which is how every rule behaved before this existed.
*/
tier: RuleTier;
why: string;
how_to_apply: string;
/**
@@ -79,6 +85,7 @@ export interface RuleHeader {
title: string;
statement: string;
topic_id: number | null;
tier: RuleTier;
/** A date (YYYY-MM-DD), not a timestamp. */
updated_at: string | null;
when_to_apply?: string;
@@ -89,13 +96,6 @@ export interface RuleHeader {
* A date (YYYY-MM-DD), or the literal "never".
*/
last_verified?: string;
/**
* Surfaced-vs-opened counts from `rule_usage_events` (milestone 333).
* Zero-filled by the list route, so a rule predating the table reads as
* "never surfaced" rather than as a missing field — which for a while is
* every rule on every install.
*/
usage?: RecordUsage;
}
export interface ApplicableRules {
@@ -125,6 +125,7 @@ export interface ApplicableRules {
truncated: boolean;
subscribed_rulebooks: { id: number; title: string }[];
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
excluded_always_on: { id: number; title: string }[];
}
// ── Rulebooks ───────────────────────────────────────────────────────
@@ -142,7 +143,7 @@ export async function createRulebook(data: { title: string; description?: string
return apiPost("/api/rulebooks", data);
}
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string }>): Promise<Rulebook> {
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string; always_on: boolean }>): Promise<Rulebook> {
return apiPatch(`/api/rulebooks/${id}`, data);
}
@@ -197,6 +198,7 @@ export interface RuleWrite {
title: string;
statement: string;
when_to_apply: string;
tier: RuleTier;
why: string;
how_to_apply: string;
order_index: number;
@@ -226,48 +228,6 @@ export async function unrelateRules(relationId: number): Promise<void> {
return apiDelete(`/api/rule-relations/${relationId}`);
}
/**
* One entry in a rule's edit history.
*
* Each entry holds the text the edit REPLACED, not the text it introduced —
* so the newest entry is what the rule said before its most recent change,
* and what that change produced is the rule as it stands now. Read the other
* way round, every diff comes out backwards.
*
* The listing form omits the long fields; open one to get them.
*/
export interface RuleVersion {
id: number;
rule_id: number;
/** Who made the edit. Null when that account has since been deleted. */
user_id: number | null;
title: string;
created_at: string;
statement?: string;
why?: string;
how_to_apply?: string;
when_to_apply?: string;
verify_with?: string;
expires_when?: string;
}
export async function listRuleVersions(ruleId: number): Promise<RuleVersion[]> {
const data = await apiGet<{ versions: RuleVersion[] }>(
`/api/rules/${ruleId}/versions`,
);
return data.versions;
}
export async function getRuleVersion(
ruleId: number, versionId: number,
): Promise<RuleVersion> {
return apiGet<RuleVersion>(`/api/rules/${ruleId}/versions/${versionId}`);
}
// No restoreRuleVersion, deliberately (milestone 323). Putting an old wording
// back goes through updateRule, which snapshots what it replaces — so the
// undo stays visible in the history like any other edit.
export async function deleteRule(id: number): Promise<void> {
return apiDelete(`/api/rules/${id}`);
}
@@ -311,6 +271,16 @@ export async function unsuppressTopicForProject(projectId: number, topicId: numb
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
}
// ── Always-on exclusions (milestone 297) ────────────────────────────────────
export async function excludeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`, {});
}
export async function includeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`);
}
/**
* One row of the staleness sweep. Unlike RuleHeader this carries the CHECK
@@ -321,6 +291,7 @@ export interface RuleVerificationRow {
id: number;
title: string;
statement: string;
tier: RuleTier;
topic_id: number | null;
project_id: number | null;
when_to_apply: string;
@@ -343,10 +314,12 @@ export interface RuleVerificationRow {
*/
export async function listRulesDueForVerification(opts: {
olderThanDays?: number;
tier?: RuleTier;
neverOnly?: boolean;
} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> {
const q = new URLSearchParams();
if (opts.olderThanDays) q.set("older_than_days", String(opts.olderThanDays));
if (opts.tier) q.set("tier", opts.tier);
if (opts.neverOnly) q.set("never_only", "true");
const qs = q.toString();
return apiGet(`/api/rules-due-for-verification${qs ? `?${qs}` : ""}`);
+9 -7
View File
@@ -1,5 +1,3 @@
import type { RecordUsage } from "@/types/usage";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
/** One canonical location of a reusable thing. A snippet that unifies several
@@ -52,11 +50,15 @@ export interface Snippet {
owner?: string | null;
}
/** Kept as a name because every consumer here says "snippet usage" — but it IS
* the shared shape, since rules answer the same question off their own table
* (milestone 333). The reasoning lives on `RecordUsage`; duplicating the four
* fields here is how the two drift. */
export type SnippetUsage = RecordUsage;
/** How often a record was put in front of an agent versus actually opened.
* A high `surfaced_count` with `pull_count: 0` is dead weight — it occupies a
* slot in every future auto-inject menu while never being used. */
export interface SnippetUsage {
surfaced_count: number;
pull_count: number;
last_surfaced_at: string | null;
last_pulled_at: string | null;
}
/** Result of the last drift check — does the recorded location and code still
* match source? The check runs agent-side (Scribe has no checkout); this is the
-44
View File
@@ -1,44 +0,0 @@
import { apiGet } from "./client";
/**
* What `/api/version` answers — the client's half of `build_version_payload`
* (`src/scribe/routes/api.py`), which is where the reasoning for the shape is
* written down.
*
* EVERY FIELD BUT `version` IS OPTIONAL, and an absent one means "this build
* does not know", not "empty". A local build has no ordering key and no
* channel, and the server says so by omitting the keys rather than sending
* `""` — emitting a placeholder would let it claim a position in an update
* order it is not part of.
*
* So a renderer must read ABSENCE, never falsiness. `build` is a number and
* `0` is a legitimate ordering key, so `v.build || "unknown"` would report a
* real value as unknown; `v.build ?? "unknown"` is the correct form.
*/
export interface VersionPayload {
/** The NAME — `YYYY.MM.DD.HHMM` from commit time. Answers "is this the same code?" */
version: string;
/** The ORDERING KEY — minutes since 2020-01-01, from build time. Absent on a local build. */
build?: number;
/** `dev` / `main` / a tag. Its own field, never folded into the name. */
channel?: string;
/** The commit the artifact was published under, so its claim can be checked against the registry. */
commit?: string;
}
/**
* SHORTER than the client's 30s default, deliberately.
*
* This readout answers "what is running?" during an incident, which is exactly
* when the server may be the thing that is unwell — and it is one static field
* off a route that does no work, so a healthy instance answers it immediately.
* Waiting the full default before saying so would leave a person staring at
* "still loading" for half a minute in the moment they are trying to find out
* whether the instance is alive at all. Eight seconds clears a slow-but-alive
* instance and tells them something quickly when it is not.
*/
const VERSION_TIMEOUT_MS = 8000;
export function fetchVersion(): Promise<VersionPayload> {
return apiGet<VersionPayload>("/api/version", { timeoutMs: VERSION_TIMEOUT_MS });
}
-26
View File
@@ -351,29 +351,3 @@
.required { color: var(--fs-error); }
.field-hint { margin: 0.3rem 0 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
/* --- usage badge ----------------------------------------------------------
"surfaced N×, opened M×" on a list row, for any record kind the retrieval
surfaces can choose: snippets and notes from note_usage_events, rules from
rule_usage_events. Promoted here from SnippetListView's scoped block when
the rule list needed the same chip (milestone 333 step 5) — a second scoped
copy is how the ninth duplicated CSS family starts (#3207).
Geometry and colour only. A view keeps its own spacing as a scoped
remainder, the way it does for every other recipe in this file. */
.usage-tag {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
color: var(--fs-text-tertiary-fg);
}
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
than the danger one, because the record isn't broken, just unearned. */
.usage-tag.usage-dead {
background: color-mix(in srgb, var(--fs-warning) 18%, transparent);
color: var(--fs-warning-fg);
}
+3 -3
View File
@@ -127,7 +127,7 @@
border: 1px solid var(--fs-error);
border-radius: var(--fs-radius-sm);
font-size: 0.85rem;
color: var(--fs-error-fg);
color: var(--fs-error);
}
.diff-view {
border: 1px solid var(--fs-border-color);
@@ -147,11 +147,11 @@
}
.diff-delete {
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
color: var(--fs-error-fg);
color: var(--fs-error);
}
.diff-insert {
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
color: var(--fs-success-fg);
color: var(--fs-success);
}
.diff-equal {
color: var(--fs-text-tertiary);
-9
View File
@@ -20,15 +20,6 @@
milestone (tier, then verification) and were byte-identical; a third would
have drifted. The pane's italic serif title is inherited by anything inside
it, so the chip resets family and style explicitly. */
/* A rule with no trigger cannot be retrieved, and since milestone 394
retrieval is the only delivery — so this marks a rule that will never
reach a session. Warning rather than error: the rule is not broken, it is
unreachable, and the fix is one field away. */
.rule-chip-inert {
color: var(--fs-warning-fg);
background: color-mix(in srgb, var(--fs-warning) 12%, var(--fs-surface-raised));
}
.rule-chip {
margin-left: 0.4rem;
font-family: var(--fs-font-body);
-6
View File
@@ -31,7 +31,6 @@
--fs-accent-faint: color-mix(in srgb, var(--fs-accent) 8%, transparent); /* The faintest accent wash */
--fs-accent-deep: color-mix(in srgb, var(--fs-accent) 70%, black); /* The accent, darkened */
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 22%, transparent); /* Heaviest accent tint */
--fs-accent-fg: color-mix(in srgb, var(--fs-accent) 45%, var(--fs-text-primary)); /* Accent TEXT on an accent tint */
--fs-gradient-cta: linear-gradient(135deg, var(--fs-accent), var(--fs-accent-deep));
--fs-glow-cta: 0 2px 10px color-mix(in srgb, var(--fs-accent) 35%, transparent);
--fs-glow-cta-hover: 0 4px 24px color-mix(in srgb, var(--fs-accent) 65%, transparent);
@@ -103,11 +102,8 @@
/* semantic */
--fs-success: var(--fs-action-primary);
--fs-success-fg: color-mix(in srgb, var(--fs-success) 45%, var(--fs-text-primary)); /* Success TEXT on a success tint */
--fs-warning: #8B6F1E;
--fs-warning-fg: color-mix(in srgb, var(--fs-warning) 50%, var(--fs-text-primary)); /* Warning TEXT on a warning tint */
--fs-error: #C04A1F;
--fs-error-fg: color-mix(in srgb, var(--fs-error) 50%, var(--fs-text-primary)); /* Error TEXT on an error tint */
--fs-info: #3D5A6E;
--fs-destructive: #6B2118; /* irreversible — deliberately not the error colour */
@@ -152,9 +148,7 @@
/* text */
--fs-text-primary: #E8E4D8; /* body, headings, labels — inverts by mode */
--fs-text-secondary: #C2BFB4;
--fs-text-secondary-fg: color-mix(in srgb, var(--fs-text-secondary) 90%, var(--fs-text-primary)); /* Secondary TEXT on a secondary tint (barely moves; no exceptions) */
--fs-text-tertiary: #9C9A92;
--fs-text-tertiary-fg: color-mix(in srgb, var(--fs-text-tertiary) 55%, var(--fs-text-primary)); /* Tertiary TEXT on a tertiary tint */
--fs-text-on-action: #E8E4D8; /* text on a filled colour — NOT mode-dependent */
/* type */
+1 -1
View File
@@ -25,7 +25,7 @@
border-color: var(--fs-accent);
}
.ctx-crumb-project {
color: var(--fs-accent-fg);
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
text-decoration: none;
+2 -2
View File
@@ -206,7 +206,7 @@ router.afterEach(() => {
background: var(--fs-accent-soft);
}
.nav-link.router-link-active {
color: var(--fs-accent-fg);
color: var(--fs-accent);
font-weight: 500;
background: color-mix(in srgb, var(--fs-accent) 25%, transparent);
box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent);
@@ -257,7 +257,7 @@ router.afterEach(() => {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fs-accent-fg);
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
padding: 0.1rem 0.35rem;
border-radius: var(--fs-radius-sm);
+2 -2
View File
@@ -137,12 +137,12 @@ function markerFor(type: DiffLine['type']): string {
.diff-delete {
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
color: var(--fs-error-fg);
color: var(--fs-error);
}
.diff-insert {
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
color: var(--fs-success-fg);
color: var(--fs-success);
}
.diff-equal {
+22 -2
View File
@@ -2,7 +2,7 @@
import { ref, computed, onMounted } from "vue";
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
import DiffView from "@/components/DiffView.vue";
import { computeDiff, type DiffLine } from "@/utils/diff";
import type { DiffLine } from "@/composables/useAssist";
import { fmtStamp } from "@/utils/dateFormat";
interface NoteVersion {
@@ -33,8 +33,28 @@ const loadingDetail = ref(false);
const diff = computed<DiffLine[]>(() => {
if (!selectedVersion.value?.body) return [];
const a = props.currentBody;
const b = selectedVersion.value.body;
return computeDiff(props.currentBody, selectedVersion.value.body);
const aLines = a.split('\n');
const bLines = b.split('\n');
const m = aLines.length, n = bLines.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i--)
for (let j = n - 1; j >= 0; j--)
dp[i][j] = aLines[i] === bLines[j]
? dp[i+1][j+1] + 1
: Math.max(dp[i+1][j], dp[i][j+1]);
const result: DiffLine[] = [];
let i = 0, j = 0;
while (i < m && j < n) {
if (aLines[i] === bLines[j]) { result.push({ type: 'equal', text: aLines[i++] }); j++; }
else if (dp[i+1][j] >= dp[i][j+1]) result.push({ type: 'delete', text: aLines[i++] });
else result.push({ type: 'insert', text: bLines[j++] });
}
while (i < m) result.push({ type: 'delete', text: aLines[i++] });
while (j < n) result.push({ type: 'insert', text: bLines[j++] });
return result;
});
async function loadVersions() {
+22 -6
View File
@@ -28,6 +28,7 @@ const emit = defineEmits<{
}>();
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
const alwaysOn = ref<{ id: number; title: string }[]>([]);
const others = ref<{ id: number; title: string }[]>([]);
const designSystems = ref<{ id: number; title: string }[]>([]);
const systemsCount = ref(0);
@@ -46,18 +47,21 @@ async function load() {
try {
if (props.mode === "decide" && props.projectId) {
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
others.value = d.rulebooks;
alwaysOn.value = d.always_on_rulebooks;
others.value = d.other_rulebooks;
designSystems.value = d.design_systems;
systemsCount.value = d.systems;
// Start from what stands today so "record" without changes is a true inherit-all.
local.value = {
exclude_always_on_rulebooks: d.excluded_always_on.map((r) => r.id),
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
design_system_id: d.design_system_id,
seed_systems: false,
};
} else {
const [rulebooks, ds] = await Promise.all([listRulebooks(), fetchDesignSystems()]);
others.value = rulebooks.map((r) => ({ id: r.id, title: r.title }));
alwaysOn.value = rulebooks.filter((r) => r.always_on).map((r) => ({ id: r.id, title: r.title }));
others.value = rulebooks.filter((r) => !r.always_on).map((r) => ({ id: r.id, title: r.title }));
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
}
} catch (e: unknown) {
@@ -67,6 +71,13 @@ async function load() {
}
}
function inherits(id: number): boolean {
return !local.value.exclude_always_on_rulebooks.includes(id);
}
function toggleInherit(id: number) {
const list = local.value.exclude_always_on_rulebooks;
local.value.exclude_always_on_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
}
function subscribed(id: number): boolean {
return local.value.subscribe_rulebooks.includes(id);
}
@@ -76,7 +87,7 @@ function toggleSubscribe(id: number) {
}
const nothingToDecide = computed(
() => !others.value.length && !designSystems.value.length,
() => !alwaysOn.value.length && !others.value.length && !designSystems.value.length,
);
async function record() {
@@ -107,11 +118,16 @@ onMounted(load);
<p v-if="loading" class="inception-muted">Loading…</p>
<p v-else-if="error" class="error-msg">{{ error }}</p>
<template v-else>
<div v-if="alwaysOn.length" class="inception-group">
<h4>Always-on rulebooks</h4>
<p class="inception-muted">Checked = inherits. Uncheck to exclude a rulebook for this project only.</p>
<label v-for="rb in alwaysOn" :key="rb.id" class="inception-choice">
<input type="checkbox" :checked="inherits(rb.id)" @change="toggleInherit(rb.id)" />
<span>{{ rb.title }}</span>
</label>
</div>
<div v-if="others.length" class="inception-group">
<h4>Subscribe to rulebooks</h4>
<p class="inception-muted">
A rulebook binds this project only if it is subscribed — nothing is inherited automatically.
</p>
<label v-for="rb in others" :key="rb.id" class="inception-choice">
<input type="checkbox" :checked="subscribed(rb.id)" @change="toggleSubscribe(rb.id)" />
<span>{{ rb.title }}</span>
@@ -227,11 +227,11 @@ const markers: Record<DiffLine["type"], string> = {
.iap-diff-equal { color: var(--fs-text-tertiary); }
.iap-diff-delete {
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
color: var(--fs-error-fg);
color: var(--fs-error);
}
.iap-diff-insert {
background: color-mix(in srgb, var(--fs-success) 10%, transparent);
color: var(--fs-success-fg);
color: var(--fs-success);
}
.iap-diff-marker {
+1 -1
View File
@@ -156,7 +156,7 @@ const groups = [
.md-btn.active {
background: color-mix(in srgb, var(--fs-accent) 14%, transparent);
color: var(--fs-accent-fg);
color: var(--fs-accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--fs-accent) 35%, transparent);
}
-198
View File
@@ -1,198 +0,0 @@
<script setup lang="ts">
/**
* The staleness sweep for NOTES: notes that assert a fact, oldest first.
*
* Sibling of RuleSweepPane, not a shared component — the two read differently
* enough that merging them would mean a prop for every difference (a rule has
* a tier and a statement; a note has a project and opens at a route). What
* they share is the SHAPE of the judgement, and that is worth copying
* deliberately rather than abstracting: the ordering carries urgency, "never"
* is categorically different from a date, and a failed check writes nothing.
*
* Lives in the Knowledge view rather than beside the rules sweep (operator's
* call, milestone 317 step 4): notes stay where notes live. The cost, accepted
* knowingly, is that there is no single screen showing every record anyone has
* left unconfirmed — /rules keeps its own.
*/
import { onMounted, ref } from "vue";
import { apiGet, apiPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
interface DueNote {
id: number;
title: string;
project_id: number | null;
verify_with: string;
expires_when: string;
last_verified: string | null;
days_since_verified: number | null;
}
const emit = defineEmits<{ "open-note": [id: number] }>();
const toast = useToastStore();
const rows = ref<DueNote[]>([]);
const loading = ref(false);
const neverOnly = ref(false);
const busyId = ref<number | null>(null);
async function reload() {
loading.value = true;
try {
const p = new URLSearchParams();
if (neverOnly.value) p.set("never_only", "1");
const data = await apiGet<{ notes: DueNote[] }>(
`/api/notes/due-for-verification?${p}`,
);
rows.value = data.notes;
} catch {
toast.show("Could not load the sweep", "error");
} finally {
loading.value = false;
}
}
async function verify(id: number, stillTrue: boolean) {
busyId.value = id;
try {
await apiPost(`/api/notes/${id}/verify`, { still_true: stillTrue });
if (stillTrue) {
// It has been confirmed, so it leaves the list — the sweep shows what
// still needs looking at, and leaving it in place would invite a second
// stamp nobody earned.
rows.value = rows.value.filter((r) => r.id !== id);
toast.show("Recorded — checked today");
} else {
// It stays. A failed check writes nothing on purpose: the note is wrong
// rather than in a state worth recording, so it keeps its place until
// someone corrects, supersedes, or unhooks it.
toast.show("Recorded as no longer true — the note keeps its place here");
}
} catch {
toast.show("Could not record that", "error");
} finally {
busyId.value = null;
}
}
onMounted(reload);
defineExpose({ reload });
</script>
<template>
<section class="sweep">
<header>
<h2>Due for verification</h2>
<p class="lede">
Notes that assert a fact about something outside your control what a
service does, how a tool behaves. Most notes are decisions and never
appear here; they have no truth value to go stale.
</p>
</header>
<div class="filters">
<label class="filter">
<input v-model="neverOnly" type="checkbox" @change="reload" />
<span>Never checked only</span>
</label>
</div>
<p v-if="loading" class="state">Loading</p>
<!-- An empty sweep is GOOD NEWS and must not read like a broken page. -->
<p v-else-if="!rows.length" class="state empty">
Nothing to check.
{{ neverOnly
? "Every note that carries a check has been confirmed at least once."
: "No note carries a check yet add one to a note that asserts a fact." }}
</p>
<ol v-else class="rows">
<li v-for="n in rows" :key="n.id" class="row">
<div class="row-head">
<button class="row-title" @click="emit('open-note', n.id)">{{ n.title }}</button>
<span class="age" :class="{ unchecked: n.days_since_verified === null }">
{{ n.days_since_verified === null
? "never checked"
: `${n.days_since_verified}d ago` }}
</span>
</div>
<dl class="check">
<dt>Check</dt>
<dd>{{ n.verify_with }}</dd>
<template v-if="n.expires_when">
<dt>Ends when</dt>
<dd>{{ n.expires_when }}</dd>
</template>
</dl>
<div class="actions">
<button :disabled="busyId === n.id" @click="verify(n.id, true)">Still true</button>
<button :disabled="busyId === n.id" @click="verify(n.id, false)">No longer true</button>
</div>
</li>
</ol>
<p v-if="rows.length" class="footnote">
Record a result only after actually running the check. No longer true stores nothing
on purpose the note is wrong rather than in a state worth recording, so it keeps its
place here until you correct it, supersede it, or remove its check.
</p>
</section>
</template>
<style scoped>
.sweep { display: flex; flex-direction: column; gap: var(--fs-space-3); }
h2 { margin: 0; font-size: 1.05rem; }
.lede {
margin: 0.35rem 0 0;
max-width: 62ch;
font-size: 0.85rem;
color: var(--fs-text-secondary);
line-height: 1.5;
}
.filters { display: flex; gap: var(--fs-space-5); align-items: center; flex-wrap: wrap; }
.filter { display: flex; align-items: center; gap: var(--fs-space-2); font-size: 0.82rem; color: var(--fs-text-secondary); }
.filter input[type="checkbox"] { accent-color: var(--fs-accent); }
.state { margin: 0; font-size: 0.9rem; color: var(--fs-text-secondary); }
.state.empty { color: var(--fs-text-tertiary); }
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-3); }
.row {
background: var(--fs-surface-raised);
border-radius: var(--fs-radius-md);
padding: var(--fs-space-3);
}
.row-head { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
.row-title {
background: none; border: none; padding: 0; cursor: pointer;
font-family: Fraunces, serif; font-style: italic; font-size: 1.02rem;
color: var(--fs-text-primary); text-align: left;
}
.row-title:hover { text-decoration: underline; }
/* The ORDER carries urgency — the top of this list is the least-confirmed
thing in the corpus. No red/amber ramp: it would restate the ordering and
force an invented "stale after N days" threshold. "Never" is marked because
it is categorically DIFFERENT from a date, not a worse one. */
.age { margin-left: auto; font-size: 0.78rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
.age.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
.check { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem var(--fs-space-3); margin: var(--fs-space-3) 0 0; }
.check dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fs-text-tertiary); }
.check dd { margin: 0; font-size: 0.82rem; color: var(--fs-text-primary); min-width: 0; overflow-wrap: anywhere; }
.actions { display: flex; gap: var(--fs-space-2); margin-top: var(--fs-space-3); }
.actions button {
cursor: pointer; font: inherit; font-size: 0.78rem;
background: var(--fs-surface-page); color: var(--fs-text-primary);
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
padding: 0.25rem 0.6rem;
}
.actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
.actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
.footnote { margin: 0; max-width: 62ch; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
</style>
+2 -2
View File
@@ -679,7 +679,7 @@ async function confirmDelete() {
font-weight: 500;
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
color: var(--fs-accent-fg);
color: var(--fs-accent);
border-radius: 999px;
padding: 0.05rem 0.45rem;
flex-shrink: 0;
@@ -689,7 +689,7 @@ async function confirmDelete() {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fs-text-tertiary-fg);
color: var(--fs-text-tertiary);
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
border-radius: 999px;
padding: 0.05rem 0.45rem;
+1 -1
View File
@@ -168,7 +168,7 @@ function focusInput() {
border-radius: 999px;
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
border: 1px solid var(--fs-accent);
color: var(--fs-accent-fg);
color: var(--fs-accent);
font-size: 0.8rem;
white-space: nowrap;
}
-62
View File
@@ -1,62 +0,0 @@
<script setup lang="ts">
/**
* "N/M used" on a list row — surfaced vs opened, for any record kind.
*
* Extracted from SnippetListView when the rule list needed the same chip
* (milestone 333 step 5). The counts read identically for both; what differs
* is the ADVICE, which is why that is a prop. A snippet surfaced repeatedly
* and never opened should probably go; a rule in the same position may simply
* have a `when_to_apply` that fires on the wrong thing, and telling an
* operator to delete it would be the wrong nudge half the time.
*/
import type { RecordUsage } from "@/types/usage";
const props = defineProps<{
usage?: RecordUsage | null;
/** What to suggest when this record looks like dead weight. Appended to the
* tooltip; kind-specific, because the remedies are. */
deadWeightAdvice: string;
/** What the record is called in the tooltip's own sentence. */
noun?: string;
}>();
/** Offered repeatedly and never opened. Three rather than one because one or
* two surfacings is noise — the record may simply not have come up in a
* relevant context yet. */
const isDeadWeight = () =>
!!props.usage && props.usage.pull_count === 0 && props.usage.surfaced_count >= 3;
/** "" renders nothing. A record nobody has surfaced yet gets no badge at all:
* "0/0" would read as a verdict when it is an absence of evidence — and on a
* freshly-migrated install that is every row. */
const label = () => {
const u = props.usage;
if (!u || u.surfaced_count === 0) return "";
return `${u.pull_count}/${u.surfaced_count} used`;
};
const title = () => {
const u = props.usage;
if (!u) return "";
const last = u.last_pulled_at
? `Last opened ${new Date(u.last_pulled_at).toLocaleDateString()}.`
: "Never opened.";
const verdict = isDeadWeight() ? ` ${props.deadWeightAdvice}` : "";
return (
`Surfaced to an agent ${u.surfaced_count}×, opened in full ` +
`${u.pull_count}×. ${last}${verdict}`
);
};
</script>
<template>
<span
v-if="label()"
class="usage-tag"
:class="{ 'usage-dead': isDeadWeight() }"
:title="title()"
>{{ label() }}</span>
</template>
<!-- The look lives in components.css (canon). Nothing scoped here on purpose:
a view that needs different spacing keeps that as its own remainder. -->
@@ -2,7 +2,7 @@
import { ref, computed } from "vue";
import { apiGet } from "@/api/client";
import DiffView from "@/components/DiffView.vue";
import { computeDiff, type DiffLine } from "@/utils/diff";
import type { DiffLine } from "@/composables/useAssist";
interface NoteVersion {
id: number;
@@ -31,7 +31,25 @@ const loadingDetail = ref(false);
const diff = computed<DiffLine[]>(() => {
if (!selectedVersion.value?.body) return [];
return computeDiff(props.currentBody, selectedVersion.value.body);
const aLines = props.currentBody.split("\n");
const bLines = selectedVersion.value.body.split("\n");
const m = aLines.length, n = bLines.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i--)
for (let j = n - 1; j >= 0; j--)
dp[i][j] = aLines[i] === bLines[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
const result: DiffLine[] = [];
let i = 0, j = 0;
while (i < m && j < n) {
if (aLines[i] === bLines[j]) { result.push({ type: "equal", text: aLines[i++] }); j++; }
else if (dp[i + 1][j] >= dp[i][j + 1]) result.push({ type: "delete", text: aLines[i++] });
else result.push({ type: "insert", text: bLines[j++] });
}
while (i < m) result.push({ type: "delete", text: aLines[i++] });
while (j < n) result.push({ type: "insert", text: bLines[j++] });
return result;
});
function formatDate(iso: string): string {
@@ -548,7 +548,7 @@ defineExpose({ reload: loadProjectNotes });
.note-tag-pill {
font-size: 0.58rem;
color: var(--fs-accent-fg);
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
border-radius: 999px;
padding: 0 0.3rem;
@@ -645,7 +645,7 @@ defineExpose({ reload: loadProjectNotes });
.btn-tag-suggestion.applied {
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
border-color: var(--fs-accent);
color: var(--fs-accent-fg);
color: var(--fs-accent);
}
.link-suggest-strip {
@@ -424,8 +424,8 @@ defineExpose({ reload: loadAll });
border-radius: 10px;
text-transform: capitalize;
}
.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent-fg); }
.ms-status-completed { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success-fg); }
.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent); }
.ms-status-completed { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success); }
.task-items {
list-style: none;
@@ -516,8 +516,8 @@ defineExpose({ reload: loadAll });
user-select: none;
margin-left: auto;
}
.status-cycler.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
.status-cycler.status-done { border-color: var(--fs-success); color: var(--fs-success-fg); background: color-mix(in srgb, var(--fs-success) 10%, transparent); }
.status-cycler.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
.status-cycler.status-done { border-color: var(--fs-success); color: var(--fs-success); background: color-mix(in srgb, var(--fs-success) 10%, transparent); }
.btn-edit-task { margin-left: 0.25rem; }
.btn-edit-task:hover { text-decoration: underline; }
@@ -13,6 +13,7 @@ import {
unsuppressRuleForProject,
suppressTopicForProject,
unsuppressTopicForProject,
includeAlwaysOnRulebook,
} from "@/api/rulebooks";
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
@@ -31,7 +32,7 @@ const ruleDetails = ref<Record<number, {
const showProjectRuleForm = ref(false);
const newProjectRule = ref({
title: "", statement: "", why: "", how_to_apply: "",
when_to_apply: "",
when_to_apply: "", tier: "always_on" as "always_on" | "conditional",
});
async function load() {
@@ -48,6 +49,11 @@ async function subscribe(rulebookId: number) {
await load();
}
async function includeBack(rulebookId: number) {
await includeAlwaysOnRulebook(props.projectId, rulebookId);
await load();
}
async function unsubscribe(rulebookId: number) {
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
await unsubscribeProject(props.projectId, rulebookId);
@@ -128,10 +134,11 @@ async function submitProjectRule() {
why: newProjectRule.value.why.trim() || undefined,
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
when_to_apply: newProjectRule.value.when_to_apply.trim() || undefined,
tier: newProjectRule.value.tier,
});
newProjectRule.value = {
title: "", statement: "", why: "", how_to_apply: "",
when_to_apply: "",
when_to_apply: "", tier: "always_on",
};
showProjectRuleForm.value = false;
await load();
@@ -203,6 +210,17 @@ watch(() => props.projectId, load);
</div>
</section>
<section v-if="applicable.excluded_always_on?.length" class="excluded">
<h3>Excluded always-on rulebooks</h3>
<p class="excluded-note">Opted out at inception these do not bind this project.</p>
<div class="chips">
<span v-for="rb in applicable.excluded_always_on" :key="rb.id" class="chip chip-excluded">
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
<button class="chip-remove" @click="includeBack(rb.id)" aria-label="Include again" title="Include again"></button>
</span>
</div>
</section>
<section class="project-rules">
<div class="section-head">
<h3>Project rules</h3>
@@ -228,13 +246,22 @@ watch(() => props.projectId, load);
></textarea>
<textarea
v-model="newProjectRule.when_to_apply"
placeholder="When to apply — the moment, in the words a session actually produces"
placeholder="When to apply — the trigger, not the instruction"
rows="2"
></textarea>
<p v-if="!newProjectRule.when_to_apply.trim()" class="trigger-hint">
Without a trigger the rule will never reach a session nothing is
preloaded, so a rule arrives only when work matches what it names.
</p>
<div class="tier-row">
<label>
<input v-model="newProjectRule.tier" type="radio" value="always_on" />
Always on
</label>
<label>
<input v-model="newProjectRule.tier" type="radio" value="conditional" />
Conditional
</label>
<span class="tier-hint">
Conditional if you had to name a system, an artifact or a moment to state the trigger.
</span>
</div>
<textarea
v-model="newProjectRule.why"
placeholder="Why (optional) — the rationale"
@@ -378,9 +405,14 @@ watch(() => props.projectId, load);
</template>
<style scoped>
.trigger-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); }
.tier-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.85rem; }
.tier-row label { display: inline-flex; align-items: center; gap: 0.3rem; }
.tier-row input { accent-color: var(--fs-accent); }
.tier-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); }
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
.chip-excluded { opacity: 0.8; text-decoration: line-through; }
.chip-excluded .chip-remove { text-decoration: none; }
.rules-tab { padding: 1rem; }
h3 {
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
@@ -2,7 +2,7 @@
import { computed, ref, watch, onMounted } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks";
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
import type { RuleTier } from "@/api/rulebooks";
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
const emit = defineEmits<{ close: [] }>();
@@ -12,6 +12,7 @@ const canon = useCanonicalSystemsStore();
const title = ref("");
const statement = ref("");
const whenToApply = ref("");
const tier = ref<RuleTier>("always_on");
const systemIds = ref<number[]>([]);
const why = ref("");
const howToApply = ref("");
@@ -68,6 +69,7 @@ async function load() {
title.value = r.title;
statement.value = r.statement;
whenToApply.value = r.when_to_apply || "";
tier.value = r.tier || "always_on";
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
why.value = r.why || "";
howToApply.value = r.how_to_apply || "";
@@ -78,6 +80,7 @@ async function load() {
title.value = "";
statement.value = "";
whenToApply.value = "";
tier.value = "always_on";
systemIds.value = [];
why.value = "";
howToApply.value = "";
@@ -96,6 +99,7 @@ async function save() {
title: title.value,
statement: statement.value,
when_to_apply: whenToApply.value,
tier: tier.value,
// Always sent, so clearing the last area actually clears it — the server
// reads a list as "these ARE the areas now".
system_ids: systemIds.value,
@@ -142,19 +146,38 @@ watch(() => props.ruleId, load);
Statement <span class="required">*</span>
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
</label>
<label :class="{ 'trigger-missing': !whenToApply.trim() }">
When to apply <span class="required">*</span>
<label>
When to apply
<textarea
v-model="whenToApply"
rows="3"
placeholder="The moment, in the words a session actually produces — “about to run git push with an earlier CI run unread”, not “when pacing actions”."
rows="2"
placeholder="The trigger, not the instruction — “before any git push”, “when a release is being cut”."
/>
</label>
<p v-if="!whenToApply.trim()" class="trigger-warning">
<strong>Without this, the rule will never reach a session.</strong>
Nothing is preloaded: a rule arrives when what someone is doing matches
its trigger, so an empty trigger leaves the rule findable by nobody.
<fieldset class="tier">
<legend>How it reaches a session</legend>
<label class="tier-opt">
<input v-model="tier" type="radio" value="always_on" />
<span>
<strong>Always on</strong>
loaded into every session.
</span>
</label>
<label class="tier-opt">
<input v-model="tier" type="radio" value="conditional" />
<span>
<strong>Conditional</strong>
arrives when its trigger fires.
</span>
</label>
<p class="tier-test">
The test: can you name the trigger <em>without</em> naming a system, an artifact type
or a moment? If the honest answer is whenever you are working, it is always on.
Conditional costs nothing when it is irrelevant, which is what lets it be as long as
it needs to be.
</p>
</fieldset>
<fieldset v-if="canon.catalog.length" class="areas">
<legend>Areas this rule is about</legend>
@@ -166,14 +189,14 @@ watch(() => props.ruleId, load);
/>
<span>{{ entry.name }}</span>
</label>
<p class="field-note">
<p class="tier-test">
What lets this rule reach a project working in that area.
</p>
</fieldset>
<fieldset class="check">
<legend>Can this rule go stale?</legend>
<p class="field-note intro">
<p class="tier-test intro">
Most rules are <em>decisions</em> they have no truth value and change only when you
change them. Leave this empty for those. Fill it in when the rule asserts a
<em>fact</em> about something outside your control, because those go false quietly.
@@ -202,7 +225,7 @@ watch(() => props.ruleId, load);
<button type="button" :disabled="verifying" @click="verify(false)">No longer true</button>
</span>
</div>
<p v-if="savedCheck" class="field-note">
<p v-if="savedCheck" class="tier-test">
Record this after actually running the check, never on the strength of the rule
sounding plausible. No longer true deliberately stores nothing the rule is wrong,
not in a state worth recording, so it stays at the top of the sweep until you fix or
@@ -219,7 +242,7 @@ watch(() => props.ruleId, load);
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
</li>
</ul>
<p class="field-note">
<p class="tier-test">
Rules that <em>fail together</em> are linked, never merged a merged rule cannot be
cited, surfaced or suppressed a clause at a time.
</p>
@@ -233,17 +256,6 @@ watch(() => props.ruleId, load);
How to apply
<textarea v-model="howToApply" rows="4" placeholder="When / where this kicks in." />
</label>
<!-- Only on an existing rule: a rule being created has no past, and an
"Edit history — none" line on a blank form reads as a broken panel.
Keyed on ruleId so switching rules reloads rather than showing the
previous rule's history under the new one's text. -->
<RuleHistoryPanel
v-if="!isCreating && ruleId !== null"
:key="ruleId"
:rule-id="ruleId"
:current="store.currentRule"
/>
</aside>
</div>
</template>
@@ -279,16 +291,9 @@ input, textarea {
}
fieldset { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: 0.75rem; margin-bottom: 1rem; }
legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary); }
.area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
.area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
.trigger-missing textarea { border-color: var(--fs-warning); }
/* --fs-warning-fg, not --fs-warning: the token set draws the distinction
between the warning HUE and warning text, and this is text. */
.trigger-warning {
margin: -0.35rem 0 0.6rem; font-size: 0.78rem; line-height: 1.45;
color: var(--fs-warning-fg);
}
.field-note { margin: 0.5rem 0 0; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
.tier-opt, .area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
.tier-opt input, .area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
.tier-test { margin: 0.5rem 0 0; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
.relations h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
.relations ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
@@ -1,273 +0,0 @@
<script setup lang="ts">
/**
* What a rule USED TO SAY — inside the slide-over, where a rule is read in
* full. Not on the list row: a history entry point there would compete with
* the row's actual job.
*
* A SIBLING OF HistoryPanel.vue, NOT A REUSE OF IT, and the reason is in its
* props: `noteId` + `currentBody`, a `NoteVersion` carrying tags and pin
* columns, a fetch of /api/notes/…, a `restore` emit, and pin/unpin buttons.
* Every one of those is note-shaped. Rules have no tags, no pins, and
* deliberately no restore, and a rule's text is EIGHT fields rather than one
* body — which changes the central question from "what changed" to "which
* fields moved".
*
* What was genuinely shared is shared: DiffView.vue takes DiffLine[] and
* nothing note-shaped, and the LCS walk now lives in utils/diff.ts, which
* this file uses rather than copying a fourth time (#3207).
*/
import { computed, onMounted, ref, watch } from "vue";
import DiffView from "@/components/DiffView.vue";
import { computeDiff } from "@/utils/diff";
import {
listRuleVersions, getRuleVersion, type Rule, type RuleVersion,
} from "@/api/rulebooks";
import { useToastStore } from "@/stores/toast";
const props = defineProps<{ ruleId: number; current: Rule | null }>();
const toast = useToastStore();
const versions = ref<RuleVersion[]>([]);
const selected = ref<RuleVersion | null>(null);
const expanded = ref(false);
const loading = ref(false);
const loadingDetail = ref(false);
// The eight TEXT fields a version carries, in the order the editor shows
// them. Narrowed to its own type rather than `keyof RuleVersion`, which would
// also admit id/rule_id/user_id/created_at — none of which is text a reader
// compares, and all of which would widen every lookup below to `number`.
// Labels rather than column names: a reader is deciding whether to open a
// row, and "How to apply" reads where "how_to_apply" has to be decoded.
type TextField =
| "title" | "statement" | "when_to_apply"
| "why" | "how_to_apply" | "verify_with" | "expires_when";
const FIELDS: Array<[TextField, string]> = [
["title", "Title"],
["statement", "Statement"],
["when_to_apply", "When to apply"],
["why", "Why"],
["how_to_apply", "How to apply"],
["verify_with", "Check"],
["expires_when", "Ends when"],
];
/**
* Which fields this edit moved.
*
* A version holds the text the edit REPLACED, so the edit is the step from
* this row to the NEXT NEWER state — the version above it in the list, or,
* for the newest row, the rule as it stands now. Comparing against the row
* below instead would attribute every change to the wrong edit.
*/
function changedFields(index: number): string[] {
const before = versions.value[index];
// `Rule` carries all eight as required strings; a RuleVersion carries them
// only once opened, which is what the undefined check below is about.
const after: Pick<Rule, TextField> | RuleVersion | null =
index === 0 ? props.current : versions.value[index - 1] ?? null;
if (!before || !after) return [];
return FIELDS
.filter(([key]) => {
// A listing row carries only the title; the rest arrive when opened.
// Undefined means NOT LOADED, which is not the same as unchanged — so a
// field nobody has fetched is claimed as neither.
const a = before[key];
const b = after[key];
if (a === undefined || b === undefined) return false;
return (a ?? "") !== (b ?? "");
})
.map(([, label]) => label);
}
/** True when this edit rewrote or removed the rule's check.
*
* Worth its own marker because editing `verify_with` silently drops
* `verified_at` (milestone 312) — the moment a rule re-entered the staleness
* sweep. That happens nowhere a reader can see it, and this row is the only
* surface that can say when it happened. */
function checkChanged(index: number): boolean {
return changedFields(index).includes("Check");
}
const diff = computed(() => {
if (!selected.value || selected.value.statement === undefined) return [];
const now = props.current?.statement ?? "";
return computeDiff(now, selected.value.statement);
});
function stamp(iso: string): string {
return iso.slice(0, 10);
}
async function load() {
loading.value = true;
try {
versions.value = await listRuleVersions(props.ruleId);
} catch {
toast.show("Could not load this rule's history", "error");
} finally {
loading.value = false;
}
}
async function open(v: RuleVersion) {
if (selected.value?.id === v.id) {
selected.value = null;
return;
}
loadingDetail.value = true;
try {
const full = await getRuleVersion(props.ruleId, v.id);
// Merged back into the list so `changedFields` can compare against real
// text once a neighbour has been opened, instead of staying blind.
const at = versions.value.findIndex((x) => x.id === v.id);
if (at >= 0) versions.value[at] = { ...versions.value[at], ...full };
selected.value = versions.value[at] ?? full;
} catch {
toast.show("Could not open that version", "error");
} finally {
loadingDetail.value = false;
}
}
onMounted(load);
watch(() => props.ruleId, () => { selected.value = null; load(); });
</script>
<template>
<section class="history">
<button class="toggle" :aria-expanded="expanded" @click="expanded = !expanded">
<span>Edit history</span>
<span class="count">{{ versions.length || "none" }}</span>
</button>
<div v-if="expanded" class="body">
<p v-if="loading" class="state">Loading</p>
<!-- Never reworded is the ordinary case, and must not read as a fault. -->
<p v-else-if="!versions.length" class="state empty">
This rule has never been reworded. Nothing was recorded before the history
existed, so an older rule starts empty too.
</p>
<template v-else>
<p class="lede">
Each entry is what the rule said <em>before</em> that edit. The wording it
was changed to is the rule as it stands above.
</p>
<ol class="rows">
<li v-for="(v, i) in versions" :key="v.id" class="row">
<button
class="row-head"
:class="{ open: selected?.id === v.id }"
@click="open(v)"
>
<span class="when">{{ stamp(v.created_at) }}</span>
<span class="fields">
{{ changedFields(i).join(", ") || "opened to compare" }}
</span>
<span v-if="checkChanged(i)" class="check-moved">check reset</span>
</button>
<div v-if="selected?.id === v.id" class="detail">
<p v-if="loadingDetail" class="state">Loading</p>
<template v-else>
<p v-if="checkChanged(i)" class="warn">
This edit changed the rule's check, which cleared its verification
stamp the rule went back to the top of the staleness sweep here.
</p>
<dl class="fields-list">
<template v-for="[key, label] in FIELDS" :key="key">
<template v-if="key !== 'statement' && v[key]">
<dt>{{ label }}</dt>
<dd>{{ v[key] }}</dd>
</template>
</template>
</dl>
<h4>Statement</h4>
<DiffView v-if="diff.length" :diff="diff" />
<p v-else class="state">The statement did not change in this edit.</p>
</template>
</div>
</li>
</ol>
</template>
</div>
</section>
</template>
<style scoped>
.history { border-top: 1px solid var(--fs-border-color); padding-top: var(--fs-space-3); }
.toggle {
display: flex; align-items: center; gap: var(--fs-space-2); width: 100%;
background: none; border: none; padding: 0; cursor: pointer;
font: inherit; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary);
}
.toggle:hover { color: var(--fs-text-primary); }
.count {
margin-left: auto; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary);
font-variant-numeric: tabular-nums;
}
.body { margin-top: var(--fs-space-3); display: flex; flex-direction: column; gap: var(--fs-space-3); }
.state { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
.state.empty { color: var(--fs-text-tertiary); }
.lede {
margin: 0; max-width: 62ch; font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary); line-height: var(--fs-leading-body);
}
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
.row { background: var(--fs-surface-raised); border-radius: var(--fs-radius-md); }
.row-head {
display: flex; align-items: baseline; gap: var(--fs-space-3); width: 100%;
background: none; border: none; cursor: pointer; text-align: left;
padding: var(--fs-space-2) var(--fs-space-3);
font: inherit; font-size: var(--fs-size-body-sm); color: var(--fs-text-primary);
}
.row-head:hover { background: var(--fs-surface-hover); border-radius: var(--fs-radius-md); }
.when {
font-variant-numeric: tabular-nums; color: var(--fs-text-secondary);
font-size: var(--fs-size-tiny);
}
.fields { color: var(--fs-text-primary); min-width: 0; overflow-wrap: anywhere; }
/* A TINT, not the solid token. `--fs-warning-fg` is defined as "warning text
ON A WARNING TINT" — painting it over solid `--fs-warning` is the same-hue
contrast failure #3141 records. The 12% mix is how theme.css builds its own
`-bg` pairs, and it keeps the value a resolvable var() rather than a raw hex
that check_design_tokens.py cannot see at all. */
.check-moved {
margin-left: auto; flex: none;
background: color-mix(in srgb, var(--fs-warning) 12%, transparent);
color: var(--fs-warning-fg);
border-radius: var(--fs-radius-pill);
padding: 0.1rem 0.5rem;
font-size: var(--fs-size-tiny); letter-spacing: var(--fs-tracking-tiny);
}
.detail {
padding: 0 var(--fs-space-3) var(--fs-space-3);
display: flex; flex-direction: column; gap: var(--fs-space-2);
}
.warn {
margin: 0; font-size: var(--fs-size-tiny); line-height: var(--fs-leading-body);
color: var(--fs-warning-fg);
background: color-mix(in srgb, var(--fs-warning) 12%, transparent);
border-radius: var(--fs-radius-sm); padding: var(--fs-space-2);
}
.fields-list { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem var(--fs-space-3); margin: 0; }
.fields-list dt {
font-size: var(--fs-size-tiny); text-transform: uppercase;
letter-spacing: var(--fs-tracking-tiny); color: var(--fs-text-tertiary);
}
.fields-list dd {
margin: 0; font-size: var(--fs-size-body-sm);
color: var(--fs-text-primary); min-width: 0; overflow-wrap: anywhere;
}
h4 { margin: var(--fs-space-2) 0 0; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); }
</style>
+3 -20
View File
@@ -1,16 +1,5 @@
<script setup lang="ts">
import type { RuleHeader } from "@/api/rulebooks";
import UsageBadge from "@/components/UsageBadge.vue";
/** The dead-weight nudge for a RULE — two remedies, not one, which is the
* whole reason this advice is per-kind. A snippet nobody opens should
* probably go. A rule nobody opens may be perfectly good and simply firing on
* the wrong thing, so "delete it" would be the wrong nudge half the time and
* the operator has to be the one who picks. */
const RULE_DEAD_WEIGHT =
"Kept arriving without being read. Either its trigger fires on the wrong " +
"work — reword “when to apply” so it says when — or it is not wanted here. " +
"Until one or the other, it takes a slot in every write it matches.";
defineProps<{ topicId: number; rules: RuleHeader[] }>();
const emit = defineEmits<{
@@ -26,14 +15,9 @@ const emit = defineEmits<{
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
<div class="title">
{{ r.title }}
<!-- Marked only when something is WRONG: every rule arrives by
retrieval now, so "conditional" stopped distinguishing anything.
A missing trigger does it means nothing can retrieve this. -->
<span
v-if="!r.when_to_apply"
class="rule-chip rule-chip-inert"
title="No trigger, so nothing can retrieve it — this rule will never reach a session"
>never surfaces</span>
<!-- Only conditional is marked: always-on is the default and
badging every row would say nothing. -->
<span v-if="r.tier === 'conditional'" class="rule-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
<!-- Present only on a rule carrying a check, so the chip's very
presence says "this one asserts a fact that can go false". -->
<span
@@ -44,7 +28,6 @@ const emit = defineEmits<{
? 'Asserts a fact nobody has confirmed yet'
: `Check last passed ${r.last_verified}`"
>{{ r.last_verified === "never" ? "unverified" : `checked ${r.last_verified}` }}</span>
<UsageBadge :usage="r.usage" :dead-weight-advice="RULE_DEAD_WEIGHT" />
</div>
<div class="statement">{{ r.statement }}</div>
<div v-if="r.when_to_apply || r.updated_at" class="meta">
@@ -10,16 +10,19 @@
*/
import { onMounted, ref } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks";
import type { RuleTier } from "@/api/rulebooks";
const emit = defineEmits<{ "open-rule": [id: number] }>();
const store = useRulebooksStore();
const neverOnly = ref(false);
const tier = ref<RuleTier | "">("");
const busyId = ref<number | null>(null);
function reload() {
return store.fetchRulesDue({
neverOnly: neverOnly.value || undefined,
tier: tier.value || undefined,
});
}
@@ -50,6 +53,14 @@ onMounted(reload);
<input v-model="neverOnly" type="checkbox" @change="reload" />
<span>Never checked only</span>
</label>
<label class="filter">
<span>Tier</span>
<select v-model="tier" @change="reload">
<option value="">any</option>
<option value="always_on">always on</option>
<option value="conditional">conditional</option>
</select>
</label>
</div>
<p v-if="store.loading" class="state">Loading</p>
@@ -57,18 +68,14 @@ onMounted(reload);
<!-- An empty sweep is GOOD NEWS, and must not read like a broken page. -->
<p v-else-if="!store.rulesDue.length" class="state empty">
Nothing to check.
{{ neverOnly ? "No rule matches these filters." : "No rule carries a check yet add one to a rule that asserts a fact." }}
{{ neverOnly || tier ? "No rule matches these filters." : "No rule carries a check yet add one to a rule that asserts a fact." }}
</p>
<ol v-else class="rows">
<li v-for="r in store.rulesDue" :key="r.id" class="row">
<div class="row-head">
<button class="row-title" @click="emit('open-rule', r.id)">{{ r.title }}</button>
<span
v-if="!r.when_to_apply"
class="rule-chip rule-chip-inert"
title="No trigger, so nothing can retrieve it — this rule will never reach a session"
>never surfaces</span>
<span v-if="r.tier === 'always_on'" class="rule-chip" title="Loaded into every session — a wrong one is wrong everywhere at once">always on</span>
<span class="age" :class="{ unchecked: r.days_since_verified === null }">
{{ r.days_since_verified === null
? "never checked"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { ref, computed, onMounted, watch } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks";
import { apiGet } from "@/api/client";
import {
@@ -18,6 +18,9 @@ const store = useRulebooksStore();
const isCreating = ref(false);
const newTitle = ref("");
const currentRulebook = computed(() =>
store.rulebooks.find((rb) => rb.id === props.rulebookId),
);
interface ProjectLite { id: number; title: string }
const projects = ref<ProjectLite[]>([]);
@@ -71,6 +74,14 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
<section class="pane">
<header>
<h2>Topics</h2>
<label v-if="currentRulebook" class="always-on-toggle" title="When on, rules from this rulebook load at session start regardless of project context">
<input
type="checkbox"
:checked="currentRulebook.always_on"
@change="store.toggleAlwaysOn(currentRulebook.id)"
/>
<span>Always on</span>
</label>
</header>
<ul>
<li
@@ -113,6 +124,12 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
<style src="@/assets/rules-shared.css" />
<style scoped>
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.always-on-toggle {
display: flex; align-items: center; gap: 0.4rem;
font-size: 0.85rem; opacity: 0.85; cursor: pointer;
user-select: none;
}
.always-on-toggle input { cursor: pointer; }
ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
li.active { background: var(--fs-accent-soft); }
@@ -31,6 +31,7 @@ async function submitNew() {
@click="emit('select', rb.id)"
>
<span class="title">{{ rb.title }}</span>
<span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span>
</li>
</ul>
<!-- Not a rulebook, and deliberately below them: a cross-cutting view over
@@ -64,6 +65,16 @@ ul { list-style: none; padding: 0; margin: 1rem 0; }
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
li.active { background: var(--fs-accent-soft); }
li:hover { background: var(--fs-surface-hover); }
.always-on-badge {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: var(--fs-accent);
color: var(--fs-text-on-action);
margin-left: auto;
}
.sweep-entry {
display: block; width: 100%; text-align: left;
margin-top: var(--fs-space-3);
+26 -5
View File
@@ -1,5 +1,4 @@
import { ref, computed, watch, type Ref } from "vue";
import { computeDiff, type DiffLine } from "@/utils/diff";
import { apiPost, apiPut, apiDelete, apiSSEStream, type SSEStreamHandle } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import {
@@ -10,16 +9,17 @@ import {
export type AssistState = "idle" | "streaming" | "review";
export type ScopeMode = "document" | "section";
// Re-exported: this composable was where DiffLine lived before the diff
// moved to a shared util, and every consumer still imports the type from here.
export type { DiffLine };
export interface AssistTarget {
text: string;
startOffset: number;
endOffset: number;
}
export interface DiffLine {
type: 'equal' | 'delete' | 'insert';
text: string;
}
export interface NoteDraft {
id: number;
note_id: number;
@@ -31,6 +31,27 @@ export interface NoteDraft {
updated_at: string;
}
function computeDiff(a: string, b: string): DiffLine[] {
const aLines = a.split('\n');
const bLines = b.split('\n');
const m = aLines.length, n = bLines.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i--)
for (let j = n - 1; j >= 0; j--)
dp[i][j] = aLines[i] === bLines[j]
? dp[i+1][j+1] + 1
: Math.max(dp[i+1][j], dp[i][j+1]);
const result: DiffLine[] = [];
let i = 0, j = 0;
while (i < m && j < n) {
if (aLines[i] === bLines[j]) { result.push({ type: 'equal', text: aLines[i++] }); j++; }
else if (dp[i+1][j] >= dp[i][j+1]) result.push({ type: 'delete', text: aLines[i++] });
else result.push({ type: 'insert', text: bLines[j++] });
}
while (i < m) result.push({ type: 'delete', text: aLines[i++] });
while (j < n) result.push({ type: 'insert', text: bLines[j++] });
return result;
}
export function useAssist(body: Ref<string>, noteId?: Ref<number | null>, projectId?: Ref<number | null>) {
const toast = useToastStore();
+1 -7
View File
@@ -31,8 +31,6 @@ export const useNotesStore = defineStore("notes", () => {
project_id?: number | null;
milestone_id?: number | null;
note_type?: string;
verify_with?: string;
expires_when?: string;
}): Promise<Note> {
try {
return await apiPost<Note>("/api/notes", data);
@@ -44,11 +42,7 @@ export const useNotesStore = defineStore("notes", () => {
async function updateNote(
id: number,
data: Partial<Pick<
Note,
"title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type"
| "verify_with" | "expires_when"
>>
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type">>
): Promise<Note> {
try {
const note = await apiPut<Note>(`/api/notes/${id}`, data);
+11 -4
View File
@@ -13,7 +13,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
// Kept so a verify re-reads the sweep with the SAME filters the operator is
// looking at — re-fetching unfiltered would silently widen the list under
// them at the moment they acted on it.
const lastSweepOpts = ref<{ olderThanDays?: number; neverOnly?: boolean }>({});
const lastSweepOpts = ref<{ olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean }>({});
const loading = ref(false);
async function fetchRulebooks() {
@@ -57,13 +57,19 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
return rb;
}
async function updateRulebook(id: number, data: Partial<Pick<Rulebook, "title" | "description">>) {
async function updateRulebook(id: number, data: Partial<Pick<Rulebook, "title" | "description" | "always_on">>) {
const rb = await api.updateRulebook(id, data);
const idx = rulebooks.value.findIndex((r) => r.id === id);
if (idx >= 0) rulebooks.value[idx] = rb;
return rb;
}
async function toggleAlwaysOn(id: number) {
const current = rulebooks.value.find((r) => r.id === id);
if (!current) return;
return updateRulebook(id, { always_on: !current.always_on });
}
async function deleteRulebook(id: number) {
await api.deleteRulebook(id);
rulebooks.value = rulebooks.value.filter((r) => r.id !== id);
@@ -106,6 +112,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
title: rule.title,
statement: rule.statement,
topic_id: rule.topic_id,
tier: rule.tier,
updated_at: rule.updated_at,
when_to_apply: rule.when_to_apply || undefined,
arose_from_id: rule.arose_from_id ?? undefined,
@@ -155,7 +162,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
/** The staleness sweep: rules asserting a fact, oldest verification first. */
async function fetchRulesDue(opts: {
olderThanDays?: number; neverOnly?: boolean;
olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean;
} = {}) {
loading.value = true;
lastSweepOpts.value = opts;
@@ -199,7 +206,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
return {
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
createRulebook, updateRulebook, deleteRulebook,
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
createTopic, updateTopic, deleteTopic,
createRule, updateRule, deleteRule, relateRules, unrelateRules,
fetchRulesDue, verifyRule,
-9
View File
@@ -34,15 +34,6 @@ export interface Note {
is_task: boolean;
note_type: NoteType;
task_kind?: TaskKind;
// The note's own check (milestone 317). Empty on almost every note — that
// is the normal case: a note with no `verify_with` is a DECISION, and there
// is nothing to go and check. Only a note asserting a fact about something
// outside the operator's control carries one. `verified_at` null while
// `verify_with` is set means NOBODY HAS EVER CONFIRMED IT, which is the
// state the sweep ranks first.
verify_with?: string;
expires_when?: string;
verified_at?: string | null;
systems?: System[];
arose_from_id?: number | null;
created_at: string;
-23
View File
@@ -1,23 +0,0 @@
/**
* How often a record was put in front of an agent, and how often one then
* opened it in full.
*
* One shape for every record kind the retrieval surfaces can choose. Snippets
* and notes are counted in `note_usage_events`; rules in `rule_usage_events`,
* which is a separate table because a note id and a rule id are different
* namespaces resolved through different maps at restore (milestone 333). The
* TABLES are separate for that reason; the READOUT is the same question, so
* the client type is one.
*
* A high `surfaced_count` with `pull_count: 0` is dead weight — it occupies a
* slot in every future menu while never being used. What to DO about that
* differs by kind, which is why the advice is a prop on the badge rather than
* a property of this type: a snippet nobody opens should probably be deleted,
* while a rule nobody opens may just be mis-triggered.
*/
export interface RecordUsage {
surfaced_count: number;
pull_count: number;
last_surfaced_at: string | null;
last_pulled_at: string | null;
}
-50
View File
@@ -1,50 +0,0 @@
/**
* Line diff — one copy, for every surface that shows what changed.
*
* WHY THIS FILE EXISTS. The same LCS walk was written out three times:
* privately in `useAssist.ts`, and again inside `HistoryPanel.vue` and
* `VersionHistorySection.vue`. The three were character-identical apart from
* quote style — nobody had diverged them on purpose, they were simply copied
* because `computeDiff` was never exported. Milestone 323 needed a fourth
* consumer (a rule's edit history), and a fourth copy is the cost #3207
* records: a fix or an improvement now has to be found in N places by someone
* who does not know N.
*/
export interface DiffLine {
type: "equal" | "delete" | "insert";
text: string;
}
/**
* Diff `a` against `b`, line by line.
*
* `delete` lines come from `a`, `insert` lines from `b` — so the caller
* decides which side reads as "before" by which argument it passes. Every
* caller here passes the CURRENT text as `a` and the older text as `b`, so a
* deletion is what the old version had and an insertion is what replaced it.
*
* O(m·n) in time and memory: fine for a note or a rule, and deliberately not
* generalised further, since nothing here diffs a file of thousands of lines.
*/
export function computeDiff(a: string, b: string): DiffLine[] {
const aLines = a.split("\n");
const bLines = b.split("\n");
const m = aLines.length, n = bLines.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i--)
for (let j = n - 1; j >= 0; j--)
dp[i][j] = aLines[i] === bLines[j]
? dp[i + 1][j + 1] + 1
: Math.max(dp[i + 1][j], dp[i][j + 1]);
const result: DiffLine[] = [];
let i = 0, j = 0;
while (i < m && j < n) {
if (aLines[i] === bLines[j]) { result.push({ type: "equal", text: aLines[i++] }); j++; }
else if (dp[i + 1][j] >= dp[i][j + 1]) result.push({ type: "delete", text: aLines[i++] });
else result.push({ type: "insert", text: bLines[j++] });
}
while (i < m) result.push({ type: "delete", text: aLines[i++] });
while (j < n) result.push({ type: "insert", text: bLines[j++] });
return result;
}
+2 -2
View File
@@ -774,7 +774,7 @@ onUnmounted(() => {
.tag-chip {
font-size: 0.7rem;
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent-fg);
color: var(--fs-accent);
border-radius: 999px;
padding: 0.1rem 0.4rem;
}
@@ -928,7 +928,7 @@ onUnmounted(() => {
}
.peek-linked-item:hover {
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
color: var(--fs-accent-fg);
color: var(--fs-accent);
}
.peek-linked-type {
+12 -94
View File
@@ -4,7 +4,6 @@ import { useRouter } from "vue-router";
import { apiGet } from "@/api/client";
import type { TaskKind, TaskStatus, TaskPriority } from "@/types/note";
import KindBadge from "@/components/KindBadge.vue";
import NoteSweepPane from "@/components/NoteSweepPane.vue";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import GraphView from "@/views/GraphView.vue";
@@ -14,7 +13,6 @@ import {
Workflow,
Search,
Share2,
ShieldCheck,
ChevronLeft,
ChevronRight,
X,
@@ -26,7 +24,7 @@ const router = useRouter();
interface KnowledgeItem {
id: number;
note_type: "note" | "task" | "process" | "snippet";
note_type: "note" | "task" | "process";
title: string;
snippet: string;
tags: string[];
@@ -44,41 +42,9 @@ interface KnowledgeItem {
task_kind?: TaskKind;
}
// ─── The facet vocabulary ─────────────────────────────────────────────────────
// Mirrors services/knowledge._FACETS, which is where it is defined for real.
// A facet spans BOTH typing axes — a record TYPE (note / process / snippet) or
// a task KIND (`task` for any, else issue / spike) — because that is what this
// feed actually holds.
//
// `plan` is still a valid facet at the API, for the 90 legacy plan-tasks, but
// it has no chip: retired in 0066, it kept a chip of its own for longer than
// `issue` — 17% of every task here — went without one (#3128). Those rows are
// still reachable under Tasks, wearing a Plan badge.
type Facet = "" | "note" | "task" | "issue" | "spike" | "snippet" | "process";
// The facets that select TASKS. Kinds are subsets of `task`, so any of them
// means the duplicate report should be comparing tasks.
const TASK_FACETS = new Set<Facet>(["task", "issue", "spike"]);
const FACET_CHIPS: [Exclude<Facet, "">, string][] = [
["note", "Notes"],
["task", "Tasks"],
["issue", "Issues"],
["spike", "Spikes"],
["snippet", "Snippets"],
["process", "Processes"],
];
// ─── View mode ────────────────────────────────────────────────────────────────
// The sweep is cross-cutting — a note that has gone false does not care which
// facet it sits under — so it REPLACES the browse list rather than filtering
// it. Filtering would mean the answer depended on which chip was active, which
// is the under-reporting the sweep exists to prevent (milestone 317 step 4).
const sweepActive = ref(false);
// ─── Filter state ─────────────────────────────────────────────────────────────
const activeType = ref<Facet>("");
const activeType = ref<"" | "note" | "task" | "plan" | "process">("");
const activeTag = ref("");
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
const searchQuery = ref("");
@@ -104,10 +70,9 @@ const dupGroups = ref<DupGroup[]>([]);
const dupSuggestion = ref("");
const dupLoading = ref(false);
const dupChecked = ref(false);
// The report follows the type filter: viewing tasks — under ANY task facet,
// including a single kind — checks tasks. Everything else checks notes, the
// kind with the most to find.
const dupKind = computed(() => (TASK_FACETS.has(activeType.value) ? "task" : "note"));
// The report follows the type filter: viewing tasks checks tasks. Anything
// else (all / plan / process) checks notes — the kind with the most to find.
const dupKind = computed(() => (activeType.value === "task" ? "task" : "note"));
async function loadDuplicates() {
dupLoading.value = true;
@@ -131,11 +96,8 @@ watch(dupKind, () => { dupChecked.value = false; dupGroups.value = []; });
// ─── Type counts ──────────────────────────────────────────────────────────────
// One number per facet, plus the grand total. Partial because the server sends
// a key only for a facet it has rows for. Kinds are subsets of `task` and are
// deliberately absent from `total` — including them would count an issue twice.
type KnowledgeCounts = Partial<Record<Exclude<Facet, "">, number>> & { total: number };
const typeCounts = ref<KnowledgeCounts>({ total: 0 });
interface KnowledgeCounts { note: number; task: number; plan: number; process: number; total: number }
const typeCounts = ref<KnowledgeCounts>({ note: 0, task: 0, plan: 0, process: 0, total: 0 });
async function fetchCounts() {
try {
@@ -272,10 +234,6 @@ function onSearchInput() {
}
watch([activeType, sortMode], () => resetAndReobserve());
// Closing the sweep remounts the feed, and with it the scroll sentinel — a
// fresh element the old observer is not watching. Without this the list loads
// its first page and then never loads another.
watch(sweepActive, (open) => { if (!open) resetAndReobserve(); });
watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
// ─── Today bar ────────────────────────────────────────────────────────────────
@@ -316,18 +274,9 @@ function isOverdue(item: KnowledgeItem): boolean {
return new Date(item.due_date) < new Date(new Date().toDateString());
}
// Each record kind opens in ITS OWN editor. A snippet used to fall through to
// /notes/:id, whose save is a plain PATCH of the body — which left the snippet's
// derived `data` mirror describing the previous version (#3128). The service now
// recomposes the mirror either way, so this is no longer the guard; it is simply
// that the note editor cannot edit a snippet's signature, language or locations,
// and offering it as the way in was always wrong. Processes stay here on
// purpose: they have no editor of their own and the note editor knows the type.
function openItem(item: KnowledgeItem) {
if (item.note_type === 'task') {
router.push(`/tasks/${item.id}`);
} else if (item.note_type === 'snippet') {
router.push(`/snippets/${item.id}`);
} else {
router.push(`/notes/${item.id}`);
}
@@ -431,14 +380,14 @@ onUnmounted(() => {
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
</button>
<button
v-for="[val, label] in FACET_CHIPS"
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['process','Processes','process']] as [string,string,string][])"
:key="val"
class="filter-btn"
:class="{ active: activeType === val }"
@click="activeType = val"
@click="activeType = (val as '' | 'note' | 'task' | 'plan' | 'process')"
>
<span class="filter-btn-label">{{ label }}</span>
<span v-if="(typeCounts[val] ?? 0) > 1" class="filter-count">{{ typeCounts[val] }}</span>
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
</button>
</div>
@@ -484,15 +433,6 @@ onUnmounted(() => {
<Share2 :size="16" />
Graph
</button>
<button
class="btn-ghost btn-compact"
:class="{ active: sweepActive }"
title="Notes that assert a fact about something outside your control, least-recently-confirmed first. Most notes are decisions and never appear."
@click="sweepActive = !sweepActive"
>
<ShieldCheck :size="16" />
Due
</button>
<button
class="btn-ghost btn-compact"
:disabled="dupLoading"
@@ -503,12 +443,6 @@ onUnmounted(() => {
</button>
</div>
<!-- The sweep replaces the feed. It is not a facet: a facet answers
"show me this kind", and this answers "show me what nobody has
confirmed" a question the type chips cannot narrow without
under-reporting it. -->
<NoteSweepPane v-if="sweepActive" @open-note="(id) => router.push(`/notes/${id}`)" />
<!-- Near-duplicate report. A proposal surface only: unlike snippets
(which merge losslessly), notes are never merged the right fix is
supersession, extraction into a reference note, or leaving parallel
@@ -546,12 +480,6 @@ onUnmounted(() => {
</template>
</div>
<!-- The whole feed stands down while the sweep is open: two answers
to two different questions on one screen is neither. Wrapped
rather than given an extra v-if branch, because the scroll
sentinel lives inside the grid and the observer must not be left
holding a ref to something that never renders. -->
<template v-if="!sweepActive">
<!-- Loading / empty -->
<div v-if="loading && items.length === 0" class="knowledge-empty">Loading…</div>
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
@@ -573,7 +501,6 @@ onUnmounted(() => {
<span v-if="item.note_type === 'note'">Note</span>
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
<span v-else-if="item.note_type === 'process'">Process</span>
<span v-else-if="item.note_type === 'snippet'">Snippet</span>
</span>
<!-- Kind sits BESIDE the type badge, not inside it: the type badge
speaks the vocabulary of this view's type filter (note / task /
@@ -625,7 +552,6 @@ onUnmounted(() => {
<span v-if="contentFetching" class="sentinel-loading">Loading</span>
</div>
</div>
</template>
</div>
<!-- Graph panel -->
@@ -827,7 +753,7 @@ onUnmounted(() => {
}
.filter-btn.active .filter-count {
background: color-mix(in srgb, var(--fs-accent) 20%, transparent);
color: var(--fs-accent-fg);
color: var(--fs-accent);
}
.filter-tag { font-size: 0.78rem; }
@@ -955,14 +881,6 @@ onUnmounted(() => {
.badge--note { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: #7A6DA8; }
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
/* Snippet and process are NEUTRAL on purpose. Both were unstyled — and the
snippet had no label either, so all 90 of them rendered an empty chip in
this feed (#3128). Giving them hues would put a third and fourth colour
beside KindBadge's warm/cool pair on the same card; a record type that
isn't an alarm reads better as plain. Standard body pair, so the contrast
is the one the palette already guarantees. */
.badge--snippet,
.badge--process { background: var(--fs-surface-raised); color: var(--fs-text-secondary); }
.k-card-body { flex: 1; padding-right: 40px; }
.k-card-title {
@@ -1008,7 +926,7 @@ onUnmounted(() => {
border-radius: 4px;
white-space: nowrap;
background: color-mix(in srgb, var(--fs-text-secondary) 15%, transparent);
color: var(--fs-text-secondary-fg);
color: var(--fs-text-secondary);
}
/* ── Task card ──────────────────────────────────────────── */
+44 -110
View File
@@ -34,14 +34,6 @@ const tags = ref<string[]>([]);
const projectId = ref<number | null>(null);
const milestoneId = ref<number | null>(null);
const noteType = ref<NoteType>("note");
// The note's own check (milestone 317). Offered only for a plain note: a
// task's decay is its status, and a snippet has verify_snippet — the service
// refuses both, so the form must not ask for what the save would reject.
const verifyWith = ref("");
const expiresWhen = ref("");
const verifiedAt = ref<string | null>(null);
const canCarryCheck = computed(() => noteType.value === "note");
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
@@ -206,41 +198,6 @@ let savedTags: string[] = [];
let savedProjectId: number | null = null;
let savedMilestoneId: number | null = null;
let savedNoteType: NoteType = "note";
let savedVerifyWith = "";
let savedExpiresWhen = "";
/** The write, in one place. Three call sites (save, create, auto-save) each
* spelled this out, so every new field had to be added three times — which is
* how one of them ends up not carrying it. */
function payload() {
return {
title: title.value,
body: body.value,
tags: tags.value,
project_id: projectId.value,
milestone_id: milestoneId.value,
note_type: noteType.value,
// "" clears the check: the REST door reads an empty string as NULL
// (NULLABLE_NOTE_TEXT), which is how a cleared form input says "remove
// this" without needing the MCP door's explicit `clear` list.
verify_with: canCarryCheck.value ? verifyWith.value : "",
expires_when: canCarryCheck.value ? expiresWhen.value : "",
};
}
/** What the form last agreed with the server about — the other half of the
* same list, and for the same reason. */
function snapshot() {
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedNoteType = noteType.value;
savedVerifyWith = verifyWith.value;
savedExpiresWhen = expiresWhen.value;
dirty.value = false;
}
function markDirty() {
dirty.value =
@@ -249,9 +206,7 @@ function markDirty() {
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
projectId.value !== savedProjectId ||
milestoneId.value !== savedMilestoneId ||
noteType.value !== savedNoteType ||
verifyWith.value !== savedVerifyWith ||
expiresWhen.value !== savedExpiresWhen;
noteType.value !== savedNoteType;
}
function onBodyUpdate(newVal: string) {
@@ -269,10 +224,12 @@ onMounted(async () => {
projectId.value = store.currentNote.project_id ?? null;
milestoneId.value = store.currentNote.milestone_id ?? null;
noteType.value = (store.currentNote.note_type as NoteType) || "note";
verifyWith.value = store.currentNote.verify_with || "";
expiresWhen.value = store.currentNote.expires_when || "";
verifiedAt.value = store.currentNote.verified_at ?? null;
snapshot();
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedNoteType = noteType.value;
}
} else {
// New note: read type from query param
@@ -303,11 +260,31 @@ async function save() {
const finalBody = body.value;
try {
if (isEditing.value) {
await store.updateNote(noteId.value!, { ...payload(), body: finalBody });
snapshot();
await store.updateNote(noteId.value!, {
title: title.value,
body: finalBody,
tags: tags.value,
project_id: projectId.value,
milestone_id: milestoneId.value,
note_type: noteType.value,
});
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedNoteType = noteType.value;
dirty.value = false;
toast.show("Note saved");
} else {
const note = await store.createNote({ ...payload(), body: finalBody });
const note = await store.createNote({
title: title.value,
body: finalBody,
tags: tags.value,
project_id: projectId.value,
milestone_id: milestoneId.value,
note_type: noteType.value,
});
dirty.value = false;
toast.show("Note created");
router.push(`/notes/${note.id}`);
@@ -344,8 +321,18 @@ async function doAutoSave() {
saving.value = true;
const finalBody = body.value;
try {
await store.updateNote(noteId.value!, { ...payload(), body: finalBody });
snapshot();
await store.updateNote(noteId.value!, {
title: title.value, body: finalBody, tags: tags.value,
project_id: projectId.value, milestone_id: milestoneId.value,
note_type: noteType.value,
});
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedNoteType = noteType.value;
dirty.value = false;
toast.show("Auto-saved");
} catch {
// Silent
@@ -509,41 +496,6 @@ onUnmounted(() => assist.clearSelection());
</select>
</div>
<!-- The note's own check (milestone 317). Shown only for a plain
note: the service refuses a check on a task or a snippet, so
offering the fields there would be a form whose save fails. -->
<template v-if="canCarryCheck">
<div class="sb-field">
<div class="sb-label-row">
<span class="sb-label">Check</span>
<span v-if="verifyWith" class="check-age" :class="{ unchecked: !verifiedAt }">
{{ verifiedAt ? `checked ${verifiedAt.slice(0, 10)}` : "never checked" }}
</span>
</div>
<!-- Phrased as the question that decides, not as a field name.
"Verify with" would get filled in on every note; "could this
become false without anyone editing it?" gets filled in on
the few that can. -->
<textarea
v-model="verifyWith"
class="sb-textarea"
rows="2"
placeholder="How would someone check this is still true? Leave empty unless this note could become false without anyone editing it."
@input="markDirty"
></textarea>
</div>
<div v-if="verifyWith" class="sb-field">
<label class="sb-label">Ends when</label>
<textarea
v-model="expiresWhen"
class="sb-textarea"
rows="2"
placeholder="What state ends it? A state, not a date — “when the forge numbers runs per workflow”, not “in six months”."
@input="markDirty"
></textarea>
</div>
</template>
<!-- Link Suggestions -->
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
<div class="sb-label-row">
@@ -726,7 +678,7 @@ onUnmounted(() => assist.clearSelection());
flex-direction: column;
}
.sb-select, .sb-input, .sb-textarea {
.sb-select, .sb-input {
width: 100%;
padding: 5px 8px;
border-radius: var(--fs-radius-sm);
@@ -738,27 +690,9 @@ onUnmounted(() => assist.clearSelection());
outline: none;
transition: border-color 0.15s;
}
.sb-select:focus, .sb-input:focus, .sb-textarea:focus {
.sb-select:focus, .sb-input:focus {
border-color: var(--fs-accent);
}
.sb-textarea {
resize: vertical;
line-height: 1.4;
box-sizing: border-box;
}
/* No red/amber ramp, matching RuleSweepPane: a colour scale would restate the
sweep's ordering and force an invented "stale after N days" threshold.
"Never" is marked because it is categorically different from a date, not a
worse one it means nobody has ever confirmed the claim. */
.check-age {
font-size: 0.7rem;
color: var(--fs-text-secondary);
font-variant-numeric: tabular-nums;
}
.check-age.unchecked {
font-style: italic;
color: var(--fs-text-tertiary);
}
/* Link Suggestions */
.link-suggest-field { gap: 0.4rem; }
+1 -1
View File
@@ -416,7 +416,7 @@ async function convertToTask() {
}
.badge-note {
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
color: var(--fs-accent-fg);
color: var(--fs-accent);
border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
}
.badge-task {
+7 -4
View File
@@ -716,6 +716,9 @@ async function confirmDelete() {
/>
<p v-else-if="project.inception" class="inception-line">
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
<template v-if="project.inception.choices.exclude_always_on_rulebooks.length">
· excludes {{ project.inception.choices.exclude_always_on_rulebooks.length }} always-on rulebook(s)
</template>
<template v-if="project.inception.choices.subscribe_rulebooks.length">
· subscribes {{ project.inception.choices.subscribe_rulebooks.length }}
</template>
@@ -1284,8 +1287,8 @@ async function confirmDelete() {
.stat-todo { background: color-mix(in srgb, var(--fs-text-tertiary) 8%, transparent); color: var(--fs-text-secondary); border-color: var(--fs-border-color); }
.stat-inprogress { background: color-mix(in srgb, #3b82f6 10%, transparent); color: #3b82f6; border-color: color-mix(in srgb, #3b82f6 28%, transparent); }
.stat-done { background: color-mix(in srgb, var(--fs-success) 10%, transparent); color: var(--fs-success-fg); border-color: color-mix(in srgb, var(--fs-success) 28%, transparent); }
.stat-notes { background: color-mix(in srgb, var(--fs-accent) 8%, transparent); color: var(--fs-accent-fg); border-color: color-mix(in srgb, var(--fs-accent) 22%, transparent); }
.stat-done { background: color-mix(in srgb, var(--fs-success) 10%, transparent); color: var(--fs-success); border-color: color-mix(in srgb, var(--fs-success) 28%, transparent); }
.stat-notes { background: color-mix(in srgb, var(--fs-accent) 8%, transparent); color: var(--fs-accent); border-color: color-mix(in srgb, var(--fs-accent) 22%, transparent); }
/* ── Pattern-library coverage card ───────────────────────────── */
.coverage-card {
@@ -1468,7 +1471,7 @@ async function confirmDelete() {
.tab-btn.active .tab-count {
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
border-color: color-mix(in srgb, var(--fs-accent) 30%, transparent);
color: var(--fs-accent-fg);
color: var(--fs-accent);
}
/* ── Tasks view ──────────────────────────────────────────────── */
@@ -1704,7 +1707,7 @@ async function confirmDelete() {
border-radius: 3px;
margin-left: auto;
}
.col-add-btn:hover { color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
.col-add-btn:hover { color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
.kanban-cards { display: flex; flex-direction: column; gap: 0.3rem; }
+15 -171
View File
@@ -9,7 +9,6 @@ import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
import { fetchVersion, type VersionPayload } from "@/api/version";
const store = useSettingsStore();
const authStore = useAuthStore();
@@ -86,7 +85,6 @@ const kbWritePathEnabled = ref(true);
// code embeddings sit on a much higher similarity floor than prose, so 0.55 let
// unrelated code through (#2223). Shares top-k, not the threshold.
const kbWritePathThreshold = ref("0.68");
const kbRuleHintThreshold = ref("0.72");
// Near-duplicate report floors, one per record kind (services/dedup.py).
// Snippets are single-chunk, so their floor sits below the 0.90 write-time
// gate and catches what it lets through. Notes/tasks are scored at chunk
@@ -149,17 +147,12 @@ async function saveKbInject() {
// Same `|| default` reasoning: falling back to 0 would surface every
// snippet in the corpus on every edit, which is the failure this knob fixes.
const wpT = Math.min(1, Math.max(0, Number(kbWritePathThreshold.value) || 0.68));
// Same `|| default` reasoning again, and it bites harder here: a rule hint
// fires on every write, so a fallback of 0 would attach a standing rule to
// every edit in the session.
const rhT = Math.min(1, Math.max(0, Number(kbRuleHintThreshold.value) || 0.72));
kbInjectThreshold.value = String(t);
kbInjectTopK.value = String(k);
kbDupThresholdSnippet.value = String(dupSnip);
kbDupThresholdNote.value = String(dupNote);
kbDupThresholdTask.value = String(dupTask);
kbWritePathThreshold.value = String(wpT);
kbRuleHintThreshold.value = String(rhT);
savingKbInject.value = true;
kbInjectSaved.value = false;
try {
@@ -172,10 +165,6 @@ async function saveKbInject() {
// measurements that split them.
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
kb_writepath_threshold: String(wpT),
// A THIRD corpus with a third bar — see RULEHINT_DEFAULT_THRESHOLD
// in services/plugin_context.py for why rules cannot share the
// code threshold any more than code could share the prose one.
kb_rulehint_threshold: String(rhT),
kb_duplicate_threshold_snippet: String(dupSnip),
kb_duplicate_threshold_note: String(dupNote),
kb_duplicate_threshold_task: String(dupTask),
@@ -198,47 +187,7 @@ const changingPassword = ref(false);
const invalidatingSessions = ref(false);
const exporting = ref(false);
const restoring = ref(false);
// Backup, export and restore walk the whole store, so they are slow BY DESIGN
// and the client's ordinary 30s default would cut them off mid-work. They are
// still bounded: rule 156 asks for a deadline, not a short one, and "no ceiling
// at all" is what leaves a restore that died server-side spinning forever.
const BULK_TRANSFER_TIMEOUT_MS = 10 * 60 * 1000;
function bulkDeadline(): AbortSignal {
return AbortSignal.timeout(BULK_TRANSFER_TIMEOUT_MS);
}
// ── What's running (#3127 checklist 12) ─────────────────────────────────
// Three states kept apart, because collapsing any two of them is the defect
// this readout exists to remove: `null` + no error = not asked yet (the Config
// tab has not been opened); a payload = answered, with each ABSENT field shown
// as "unknown"; `versionError` = the fetch itself failed, which is its own
// thing and must never render as a blank or as a plausible-looking value.
const versionInfo = ref<VersionPayload | null>(null);
const versionLoading = ref(false);
const versionError = ref("");
const commitCopied = ref(false);
async function loadVersionPanel() {
if (versionLoading.value) return;
versionLoading.value = true;
versionError.value = "";
try {
versionInfo.value = await fetchVersion();
} catch (e) {
versionInfo.value = null;
versionError.value = apiErrorMessage(e, "Could not reach the instance to ask what it is running.");
} finally {
versionLoading.value = false;
}
}
async function copyCommit() {
if (!versionInfo.value?.commit) return;
await copyToClipboard(versionInfo.value.commit);
commitCopied.value = true;
setTimeout(() => { commitCopied.value = false; }, 2000);
}
const appVersion = ref('dev');
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
@@ -252,7 +201,6 @@ function _loadTabContent(tab: string) {
else if (tab === "logs") loadLogsPanel();
else if (tab === "groups") loadGroupsPanel();
else if (tab === "areas") canonStore.fetchCatalog(true);
else if (tab === "config" && !versionInfo.value) loadVersionPanel();
}
if (tab === "apikeys") { fetchApiKeys(); }
}
@@ -606,6 +554,10 @@ function toggleProfileWorkDay(day: string) {
function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) }
onMounted(async () => {
try {
const v = await apiGet<{ version: string }>('/api/version')
appVersion.value = v.version
} catch { /* non-critical */ }
await store.fetchSettings();
newEmail.value = authStore.user?.email ?? "";
@@ -621,9 +573,6 @@ onMounted(async () => {
kbInjectTopK.value = allSettings.kb_autoinject_top_k;
}
kbWritePathEnabled.value = allSettings.kb_writepath_enabled !== "false";
if (allSettings.kb_rulehint_threshold !== undefined) {
kbRuleHintThreshold.value = allSettings.kb_rulehint_threshold;
}
if (allSettings.kb_writepath_threshold !== undefined) {
kbWritePathThreshold.value = allSettings.kb_writepath_threshold;
}
@@ -778,7 +727,7 @@ async function exportData(scope: "user" | "full") {
exporting.value = true;
try {
const url = scope === "full" ? "/api/admin/backup" : "/api/admin/backup?scope=user";
const res = await fetch(url, { signal: bulkDeadline() });
const res = await fetch(url);
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
@@ -803,7 +752,7 @@ const exportingNotes = ref(false);
async function exportNotes(format: "markdown" | "json") {
exportingNotes.value = true;
try {
const res = await fetch(`/api/export?format=${format}`, { signal: bulkDeadline() });
const res = await fetch(`/api/export?format=${format}`);
if (!res.ok) throw new Error(`Error ${res.status}`);
const blob = await res.blob();
const ext = format === "json" ? "json" : "zip";
@@ -1032,7 +981,6 @@ async function handleRestoreFile(event: Event) {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
signal: bulkDeadline(),
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
@@ -1469,29 +1417,6 @@ async function deleteUser(userId: number) {
location, not by resemblance.
</p>
</div>
<div class="field">
<label for="kb-rulehint-threshold">Standing-rule confidence threshold (01)</label>
<input
id="kb-rulehint-threshold"
v-model="kbRuleHintThreshold"
type="number"
min="0"
max="1"
step="0.01"
class="fs-input input"
style="max-width: 8rem"
/>
<p class="field-hint">
The same hint can mention a standing rule whose trigger resembles what's
being written — only rules marked <em>conditional</em>, since always-on
ones are already loaded. Stricter again than the threshold above, because
there are far fewer rules than snippets: with a small set, something
always ranks first, so the bar has to carry more of the judgement.
Raise it if rules keep arriving unread; lower it if a rule you needed
never showed up. Settings → check the pull-through in
<code>retrieval_telemetry</code> to see which is happening.
</p>
</div>
<!-- A design system belongs to a PROJECT, and the picker for it lives on
the project. There was a setting here that designated the system
this install's own interface was built from; it only ever described
@@ -2184,48 +2109,6 @@ async function deleteUser(userId: number) {
<!-- ── Admin ── -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
<section class="settings-section full-width">
<h2>What's running</h2>
<p class="section-desc">
The build serving this page. Paste the commit into a <code>:sha</code> image
lookup to check the registry and the app agree about what was published.
</p>
<div v-if="versionLoading" class="state-msg">Reading the ledger&hellip;</div>
<div v-else-if="versionError" class="error-msg">
{{ versionError }}
<button class="btn-ghost btn-compact version-retry" @click="loadVersionPanel">Try again</button>
</div>
<dl v-else-if="versionInfo" class="version-grid">
<dt>Version</dt>
<dd class="version-value">{{ versionInfo.version }}</dd>
<dt>Channel</dt>
<dd :class="versionInfo.channel === undefined ? 'version-unknown' : 'version-value'">
{{ versionInfo.channel ?? "unknown" }}
</dd>
<dt>Commit</dt>
<dd v-if="versionInfo.commit" class="version-value version-commit">
<span class="version-sha">{{ versionInfo.commit }}</span>
<button class="btn-ghost btn-compact" @click="copyCommit">
{{ commitCopied ? "Copied" : "Copy" }}
</button>
</dd>
<dd v-else class="version-unknown">unknown</dd>
<dt>Build</dt>
<!-- The ordering key, kept because its ABSENCE is the diagnostic one:
no key means this build is not part of any update order, which is
what a local or hand-built image looks like. `??` not `||` 0 is
a legitimate key. -->
<dd :class="versionInfo.build === undefined ? 'version-unknown' : 'version-value'">
{{ versionInfo.build ?? "unknown" }}
</dd>
</dl>
<div v-else class="empty-msg">Nothing asked yet.</div>
</section>
<section class="settings-section full-width">
<h2>Application URL</h2>
<p class="section-desc">
@@ -2849,7 +2732,7 @@ async function deleteUser(userId: number) {
background: var(--fs-surface-raised);
}
.sidebar-item.active {
color: var(--fs-accent-fg);
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 8%, transparent);
border-left-color: var(--fs-accent);
font-weight: 500;
@@ -2885,45 +2768,6 @@ async function deleteUser(userId: number) {
letter-spacing: 0.07em;
color: var(--fs-text-tertiary);
}
/* What's running — a definition list of instance facts. Spacing/geometry only;
colour and type come from the tokens. */
.version-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.4rem 1rem;
margin: 0;
align-items: baseline;
}
.version-grid dt {
font-size: 0.8rem;
color: var(--fs-text-secondary);
}
.version-grid dd {
margin: 0;
font-size: 0.875rem;
font-family: var(--fs-font-mono);
color: var(--fs-text-primary);
}
/* An absent field reads as absent — never as a blank, and never styled to look
like a value it does not have (#3127 checklist 12). */
.version-grid dd.version-unknown {
font-family: inherit;
font-style: italic;
color: var(--fs-text-tertiary);
}
.version-commit {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.version-sha {
overflow-wrap: anywhere;
}
.version-retry {
margin-left: 0.5rem;
}
.section-desc {
margin: 0 0 1rem;
font-size: 0.875rem;
@@ -3255,7 +3099,7 @@ async function deleteUser(userId: number) {
border-radius: var(--fs-radius-sm);
}
.role-admin {
color: var(--fs-accent-fg);
color: var(--fs-accent);
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
}
.role-user {
@@ -3335,9 +3179,9 @@ async function deleteUser(userId: number) {
text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.1rem 0.35rem; border-radius: var(--fs-radius-sm);
}
.cat-audit { color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 15%, transparent); }
.cat-usage { color: var(--fs-success-fg); background: color-mix(in srgb, var(--fs-success) 15%, transparent); }
.cat-error { color: var(--fs-error-fg); background: color-mix(in srgb, var(--fs-error) 15%, transparent); }
.cat-audit { color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 15%, transparent); }
.cat-usage { color: var(--fs-success); background: color-mix(in srgb, var(--fs-success) 15%, transparent); }
.cat-error { color: var(--fs-error); background: color-mix(in srgb, var(--fs-error) 15%, transparent); }
.method-tag {
display: inline-block;
font-size: 0.65rem; font-weight: 500; font-family: monospace;
@@ -3502,8 +3346,8 @@ async function deleteUser(userId: number) {
padding: 0.15rem 0.4rem;
border-radius: 4px;
}
.role-owner { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning-fg); }
.role-member { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary-fg); }
.role-owner { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning); }
.role-member { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary); }
.members-empty {
color: var(--fs-text-tertiary);
@@ -3684,7 +3528,7 @@ async function deleteUser(userId: number) {
.day-btn.active {
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
border-color: var(--fs-accent);
color: var(--fs-accent-fg);
color: var(--fs-accent);
font-weight: 500;
}
+3 -3
View File
@@ -242,9 +242,9 @@ onMounted(async () => {
border-radius: 4px;
white-space: nowrap;
}
.perm-viewer { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary-fg); }
.perm-editor { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent-fg); }
.perm-admin { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning-fg); }
.perm-viewer { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary); }
.perm-editor { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent); }
.perm-admin { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning); }
.empty-msg {
margin: 0;
+1 -1
View File
@@ -288,7 +288,7 @@ async function confirmDelete() {
font-family: var(--fs-font-mono);
font-size: 0.82rem;
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
color: var(--fs-accent-fg);
color: var(--fs-accent);
padding: 0.08rem 0.35rem;
border-radius: var(--fs-radius-sm);
word-break: break-all;
+61 -12
View File
@@ -9,7 +9,6 @@ import {
type SnippetListItem,
} from "@/api/snippets";
import { useToastStore } from "@/stores/toast";
import UsageBadge from "@/components/UsageBadge.vue";
const router = useRouter();
const toast = useToastStore();
@@ -199,6 +198,23 @@ function languageOf(tags: string[]): string {
return tags.find((t) => t && t !== "snippet") ?? "";
}
/** A snippet that has been offered repeatedly and never opened. The threshold
* is 3 rather than 1 because one or two surfacings is noise — the record may
* simply not have come up in a relevant context yet. */
function isDeadWeight(s: SnippetListItem): boolean {
const u = s.usage;
return !!u && u.pull_count === 0 && u.surfaced_count >= 3;
}
/** Short badge text, or "" to render nothing. A record nobody has surfaced yet
* gets no badge at all: "0 / 0" would read as a verdict when it's an absence
* of evidence. */
function usageBadge(s: SnippetListItem): string {
const u = s.usage;
if (!u || u.surfaced_count === 0) return "";
return `${u.pull_count}/${u.surfaced_count} used`;
}
/** Short label for the drift verdict, or "" when there's nothing to say.
* An expired verdict is reported as "unchecked" whatever it used to say —
* it was about code that is no longer in the record. */
@@ -238,13 +254,22 @@ function driftTitle(s: SnippetListItem): string {
return v.detail ? `${when}: ${what}. ${v.detail}` : `${when}: ${what}.`;
}
/** The dead-weight nudge for a SNIPPET, passed to the shared badge. Kept here
* rather than inside the component because the remedy is kind-specific — a
* rule in the same position gets different advice (milestone 333 step 5). */
const SNIPPET_DEAD_WEIGHT =
"Offered repeatedly without ever being opened — consider rewriting its " +
"“when to reach for it” so it says when, or deleting it. It takes a slot " +
"in every future auto-inject menu.";
function usageTitle(s: SnippetListItem): string {
const u = s.usage;
if (!u) return "";
const last = u.last_pulled_at
? `Last opened ${new Date(u.last_pulled_at).toLocaleDateString()}.`
: "Never opened.";
const verdict = isDeadWeight(s)
? " Offered repeatedly without ever being opened — consider rewriting its" +
" “when to reach for it” so it says when, or deleting it. It takes a slot" +
" in every future auto-inject menu."
: "";
return (
`Surfaced to an agent ${u.surfaced_count}×, opened in full ` +
`${u.pull_count}×. ${last}${verdict}`
);
}
</script>
<template>
@@ -431,7 +456,14 @@ const SNIPPET_DEAD_WEIGHT =
<span v-if="driftBadge(s)" class="drift-tag" :title="driftTitle(s)">
{{ driftBadge(s) }}
</span>
<UsageBadge :usage="s.usage" :dead-weight-advice="SNIPPET_DEAD_WEIGHT" />
<span
v-if="usageBadge(s)"
class="usage-tag"
:class="{ 'usage-dead': isDeadWeight(s) }"
:title="usageTitle(s)"
>
{{ usageBadge(s) }}
</span>
<span v-if="s.shared" class="shared-tag" :title="`Shared by ${s.owner ?? 'another user'} — a suggestion, not your own record`">
by {{ s.owner ?? "another user" }}
</span>
@@ -678,7 +710,7 @@ const SNIPPET_DEAD_WEIGHT =
flex-shrink: 0;
white-space: nowrap;
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent-fg);
color: var(--fs-accent);
}
.snippet-when {
@@ -707,7 +739,7 @@ const SNIPPET_DEAD_WEIGHT =
border-radius: 4px;
white-space: nowrap;
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
color: var(--fs-text-tertiary-fg);
color: var(--fs-text-tertiary);
}
.dup-action {
@@ -722,7 +754,24 @@ const SNIPPET_DEAD_WEIGHT =
border-radius: 4px;
white-space: nowrap;
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
color: var(--fs-error-fg);
color: var(--fs-error);
}
.usage-tag {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: 4px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
color: var(--fs-text-tertiary);
}
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
than the danger one, because the record isn't broken, just unearned. */
.usage-tag.usage-dead {
background: color-mix(in srgb, var(--fs-warning) 18%, transparent);
color: var(--fs-warning);
}
/* Header + select-mode */
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "2026.09.11.2026",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
"version": "0.1.47",
"author": {
"name": "Bryan Van Deusen"
},
+3 -8
View File
@@ -5,7 +5,7 @@ instance into a first-class Claude Code extension:
- **MCP tools** over your notes, tasks, projects, milestones, systems, and
rulebook (the `scribe` server).
- **Session-start push channel** — a `SessionStart` hook injects your
- **Session-start push channel** — a `SessionStart` hook injects your always-on
rules + active-project context so Scribe surfaces *without being asked*.
- **Prior-art recall on writes** — a `PreToolUse` hook on Write/Edit checks the
file about to be written against your recorded snippets (what's kept at that
@@ -78,13 +78,8 @@ On install you'll be asked for:
## Notes
- **Do not hand-edit `version` in `.claude-plugin/plugin.json`.** It is minted
from the clock — run `python3 scripts/mint_plugin_version.py` (or `make
mint-plugin`, where `make` is installed) after changing anything under
`plugin/`, and commit the result. The installer decides whether to refresh the cache it
executes from by comparing that string, so content that ships without a new
version reaches the repo and stops there (#2209). CI fails the lane if you
forget.
- Set a `version` bump in `.claude-plugin/plugin.json` per release so clients
pick up changes.
- The session-start, auto-inject and prior-art hooks need only a **read**-scoped
key; the MCP tools need **write** scope to create/update. Every hook is a GET
for that reason — a read key cannot POST.
-9
View File
@@ -33,15 +33,6 @@
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_prior_art.sh\""
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_tool_rules.sh\""
}
]
}
],
"PostToolUse": [
+1 -6
View File
@@ -175,11 +175,6 @@ while IFS= read -r rel_path; do
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | jq -sRr '@uri' 2>/dev/null) || derive_seen=""
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
fi
# The rules marker is gone with the resident set it aged (milestone 394).
# A session no longer holds a fixed set of rules from turn zero, so there
# is nothing that can have drifted since it loaded them — each rule is
# retrieved at the moment it applies.
etag_q=""
if [ -n "$path_enc" ]; then
# 8s, not the pre-write hook's 5: this hook runs AFTER the tool, so it
# gates nothing the session is waiting on, and the first prior-art call
@@ -189,7 +184,7 @@ while IFS= read -r rel_path; do
reached=1
body=$(curl -fsS --max-time 8 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${shapes_q}${etag_q}" 2>/dev/null) || { body=""; reached=0; }
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${shapes_q}" 2>/dev/null) || { body=""; reached=0; }
# A call that was owed and didn't come back is said, once per outage
# (#2932) — shared marker with the pre-write hook, so one outage is one
# line however the code was written.
-36
View File
@@ -7,18 +7,6 @@
# only — never bodies; the agent calls get_note(id) to pull anything it judges
# relevant. Most turns inject nothing.
#
# TWO ARMS SINCE #3852, on one request. Rules and preferences are retrieved
# against the same prompt and returned in the same payload, ahead of the notes
# menu. That arm exists because the two act arms are keyed on a file write or
# a command, so a rule governing what to SAY — extract intent from loose
# phrasing, raise a conflict before acting, end a finding with an offer — had
# no moment to fire at. The operator's message is the only query that exists
# before a response is composed.
#
# The two arms are gated separately server-side: turning the notes menu off
# leaves rules arriving, because they are different claims with different
# costs of being missed.
#
# Best-effort enrichment ONLY: unlike the SessionStart channel there is no
# static floor here. If the instance is unconfigured/unreachable, or anything
# fails, the hook stays SILENT and exits 0 — it must never block a prompt.
@@ -77,32 +65,16 @@ fi
# Per-session dedup: ids already injected this session are skipped.
state_dir="${TMPDIR:-/tmp}/scribe-autoinject"
mkdir -p "$state_dir" 2>/dev/null || true
# RULES DEDUP IN A DIFFERENT DIRECTORY, and it has to be this one. The rule
# ledger is SHARED by every arm that can name a rule — the two PreToolUse
# hooks already keep it under scribe-priorart — so that one session keeps ONE
# list and a rule named here is not re-announced before the next Bash call.
# A private copy here would make each arm's "already seen" mean something
# different, which is the state #3749/#3750 exist to keep coherent. The
# directory name is the prior-art hook's history, not a scope claim.
rule_state_dir="${TMPDIR:-/tmp}/scribe-priorart"
mkdir -p "$rule_state_dir" 2>/dev/null || true
idfile=""
rulefile=""
exclude_q=""
if [ -n "$session_id" ]; then
# session_id is an opaque token from Claude Code; keep only filename-safe chars.
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
idfile="$state_dir/${safe_sid}.ids"
rulefile="$rule_state_dir/${safe_sid}.rules.ids"
if [ -f "$idfile" ]; then
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
fi
# AGED, not read flat: an exclusion that never expires means a rule surfaced
# once in a long session is silenced for the rest of it, even as the session
# stops holding what it was told. scribe_rules_live carries the reasoning.
rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && exclude_q="${exclude_q}&exclude_rule_ids=${rule_seen}"
fi
body=$(curl -fsS --max-time 5 \
@@ -117,14 +89,6 @@ context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
if [ -n "$idfile" ]; then
printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
fi
# Rules onto the SHARED ledger, stamped so they can age out. Only FRESH ids
# come back in rule_ids (#3752) — a rule rendered as a repeat is already on
# the ledger, and re-appending it would keep pushing its stamp forward so it
# never aged at all.
if [ -n "$rulefile" ]; then
printf '%s' "$body" | jq -r '.rule_ids[]? // empty' 2>/dev/null \
| scribe_rules_append "$rulefile"
fi
jq -n --arg c "$context" \
'{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $c}}'
-103
View File
@@ -16,9 +16,6 @@
# scribe_reached STATE SID the server answered: the next outage speaks again
# scribe_config sets `url` + `token` from the env, returns 0
# only if BOTH are usable (#2278)
# scribe_rules_live FILE live rule ids from the exclusion ledger,
# comma-joined; entries age out (#3751)
# scribe_rules_append FILE stdin ids -> the ledger, timestamped
#
# Sourced, not executed: `. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"`.
@@ -178,103 +175,3 @@ scribe_unreached() {
scribe_reached() {
rm -f "$1/$2.unreached" 2>/dev/null || true
}
# ---------------------------------------------------------------------------
# THE RULE EXCLUSION LEDGER, and how entries in it AGE (#3751).
#
# Two hooks write this file and two read it, which is the whole reason the
# parsing lives here: `scribe_prior_art.sh` and `scribe_tool_rules.sh` share
# one ledger so a rule named by one arm is not re-offered by the other, and a
# format only one of them understood would break that on the first read.
#
# WHAT PROBLEM AGEING SOLVES. #3749 clears the ledger when an EVENT destroys
# context — a compaction or a /clear. This is the case with no event at all: a
# long session where the rule was named two hundred turns ago and has simply
# fallen out of attention. It is #3702's argument at the tier level (present in
# context and salient at the moment are different properties) applied to time
# instead of to tier.
#
# PER-ENTRY TIMESTAMPS, NOT A FILE MTIME. Clearing the whole ledger when the
# file is old is one line of shell and wrong in exactly the session that needs
# it: a single recent write keeps every stale id alive, and the ids that go
# stale first are the ones from the rules that fire most.
#
# WALL TIME, NOT A TURN COUNT, and the trade is real rather than dismissed. A
# turn count is a truer model of salience — an idle session does not forget —
# but a hook has no turn number without keeping its own counter, which is a
# second piece of session state to write, read, clear on compaction and get
# wrong. Wall time is available from `date` and costs nothing. The failure mode
# it accepts is a session left idle over lunch treating its rules as forgotten,
# which produces one extra full line per rule and no other harm.
_SCRIBE_RULE_TTL=2700
# 45 MINUTES, and the reasoning rather than the number (rule 32).
#
# There is no data on this yet, so it is a judgement made to be revised — the
# telemetry that would settle it is the one #3807 just built, and a reading of
# how often an aged-out rule gets PULLED after it returns is what should move
# this.
#
# Too short and the exclusion stops existing and the repetition it prevents
# comes back. Too long and it never fires at all in a session short enough to
# matter. 45 minutes is about one working stretch on a single task: long enough
# that a rule does not re-announce itself while you are still doing the thing
# it governs, short enough that a multi-hour session gets a genuine refresh
# rather than one 9am mention.
#
# Being wrong on the short side is now the cheaper error, which is why this
# leans short. Since #3750 an excluded rule is REFERENCED rather than withheld,
# so the ledger is no longer the only thing standing between a session and a
# rule it has forgotten — an expired entry costs one full line instead of one
# short one, and the exclusion re-arms the moment it is spent.
# Live ids from a ledger, comma-joined for `exclude_rule_ids`. Empty output for
# a missing, empty or fully-aged file — the callers already treat "" as "send
# no exclusions".
#
# THE LAST ENTRY FOR AN ID WINS, and this is what stops a rule ping-ponging.
# The file is append-only, so a rule that ages out, gets surfaced fresh and is
# appended again has TWO lines. Reading the first would leave it permanently
# expired and it would re-announce itself on every single call from then on —
# the loudest possible failure, from the mechanism meant to quieten things.
# Appends are chronological, so the last line for an id is its most recent.
#
# A BARE ID — no tab, no timestamp — IS LIVE. That is the pre-#3751 format, and
# a session in flight when this ships has a ledger full of them. Treating
# unknown as expired would make every one of those sessions re-announce every
# rule it had already been told, all at once, which is precisely the noise this
# exists to prevent. Unknown means "not measured" and never "old" — the same
# null discipline the retrieval_logs columns use. Those entries simply never
# age, which is bounded: the session ends.
scribe_rules_live() {
local f="$1" now
[ -n "$f" ] && [ -f "$f" ] || return 0
now=$(date +%s 2>/dev/null) || now=0
awk -F'\t' -v now="$now" -v ttl="$_SCRIBE_RULE_TTL" '
{
id = $1
gsub(/[^0-9]/, "", id)
if (id == "") next
if (!(id in seen)) { seen[id] = 1; seq[++n] = id }
stamp[id] = ($2 ~ /^[0-9]+$/) ? $2 : ""
}
END {
out = ""
for (i = 1; i <= n; i++) {
id = seq[i]
if (stamp[id] != "" && now > 0 && (now - stamp[id]) > ttl) continue
out = out (out == "" ? "" : ",") id
}
print out
}
' "$f" 2>/dev/null || true
}
# Append surfaced ids, stamped. Reads ids on stdin, one per line — the shape
# `jq -r '(.rule_ids // [])[]?'` already produces at both call sites.
scribe_rules_append() {
local f="$1" now
[ -n "$f" ] || return 0
now=$(date +%s 2>/dev/null) || now=0
awk -v ts="$now" 'NF { print $1 "\t" ts }' >> "$f" 2>/dev/null || true
}
+4 -6
View File
@@ -204,11 +204,10 @@ if [ -n "$session_id" ]; then
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | jq -sRr '@uri' 2>/dev/null) || derive_seen=""
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
fi
# Ageing, not a flat read (#3751), and the ONLY ledger here that ages: the
# note channels above are a different question with a different answer, and
# this arm's sibling hook reads the same rule file through the same helper.
rule_seen=$(scribe_rules_live "$rulefile")
if [ -f "$rulefile" ]; then
rule_seen=$(tr '\n' ',' < "$rulefile" 2>/dev/null | sed 's/,$//')
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
fi
fi
# Not `|| exit 0`: an unreachable instance must not discard a local finding
@@ -240,8 +239,7 @@ if [ -n "$body" ]; then
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
fi
if [ -n "$rulefile" ]; then
printf '%s' "$body" | jq -r '(.rule_ids // [])[]?' 2>/dev/null \
| scribe_rules_append "$rulefile"
printf '%s' "$body" | jq -r '(.rule_ids // [])[]?' 2>/dev/null >> "$rulefile" || true
fi
if [ -n "$derivefile" ]; then
printf '%s' "$body" | jq -r '(.derive_keys // [])[]?' 2>/dev/null >> "$derivefile" || true
+5 -56
View File
@@ -8,7 +8,7 @@
# does not depend on the key or the network.
#
# Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Scribe instance
# for active-project context and appends it. Config comes from
# for always-on rules + active-project context and appends it. Config comes from
# the plugin's userConfig, exported to hooks as:
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
@@ -55,53 +55,6 @@ here=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) || exit 0
event=$(cat 2>/dev/null || true)
source=$(printf '%s' "$event" | jq -r '.source // empty' 2>/dev/null) || source=""
# --- The rule ledger outlives the context it describes (#3749) ---
#
# scribe_prior_art.sh and scribe_tool_rules.sh record every rule id they have
# named in <state>/<sid>.rules.ids and hand it back as exclude_rule_ids, so a
# rule is named once per session and then goes quiet. That is right while the
# session still HOLDS what it was told, and wrong the moment it does not.
#
# A compaction summarizes the earlier injections away and does not touch the
# filesystem, so the rule ends up absent from context AND still excluded —
# unreachable for the rest of the session. The banner below tells the model to
# re-pull its ALWAYS-ON rules, but a rule an arm surfaced is conditional and is
# not in that set, so it has no other way back. The rules most likely to be in
# this state are the ones that fire most often, which is to say the ones that
# apply most.
#
# The session id survives a compaction — the etag marker further down is
# rewritten on `compact` and keyed by session_id, which is only meaningful if
# the id is stable — so the stale ledger is genuinely found again, not orphaned.
#
# CLEARED ON THE SOURCES THAT DESTROY CONTEXT, AND ONLY THOSE:
#
# compact CLEAR — summarized away; the file survived.
# clear CLEAR — context wiped.
# startup nothing to do: a new session id means a new, empty file.
# resume KEEP. The context was genuinely restored, so the ledger still
# describes what the session holds. Clearing here would re-surface
# every rule after a restore that lost nothing — the mirror error.
# fork KEEP, and the answer is the same whichever way forks are keyed: a
# fork carries the conversation, so if it inherits the id the ledger
# is accurate, and if it gets a new one the file is empty anyway.
#
# ONLY the rules ledger. The same directory holds .ids / .sync.ids /
# .derive.ids for the note arms. Whether a surfaced NOTE should return after a
# compaction is a different question with a different answer, and leaving those
# alone is a decision rather than an oversight.
case "$source" in
compact|clear)
sid=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || sid=""
if [ -n "$sid" ]; then
safe_sid=$(printf '%s' "$sid" | tr -c 'A-Za-z0-9._-' '_')
# Best-effort, like every other filesystem touch in these hooks: a ledger
# that cannot be removed costs a repeated exclusion, never a session.
rm -f "${TMPDIR:-/tmp}/scribe-priorart/${safe_sid}.rules.ids" 2>/dev/null || true
fi
;;
esac
out=""
# Append $1 to $out, separated by a horizontal rule when $out already has content.
append() { if [ -n "$out" ]; then out="${out}"$'\n\n---\n\n'"$1"; else out="$1"; fi; }
@@ -156,13 +109,9 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
# The rules marker is gone with the resident set it described
# (milestone 394). Nothing is preloaded, so there is no set whose
# drift a later write could be told about — a rule is retrieved at
# the moment it applies, which cannot be stale.
[ -z "$dyn" ] && status="> ⚠️ Scribe: live project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\` as needed."
[ -z "$dyn" ] && status="> ⚠️ Scribe: live rules/project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\` as needed."
elif [ -n "$url" ] && [ -z "$token" ]; then
status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`."
status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
elif [ -z "$url" ] && [ -z "$token" ]; then
# NEITHER value arrived. Previously this case stayed silent as "an unconfigured
# install", which made issue #2198 invisible for weeks: a *casing* bug here
@@ -171,7 +120,7 @@ elif [ -z "$url" ] && [ -z "$token" ]; then
# silently disabled auto-inject and the write-path trigger too. It is not a
# benign state — the plugin prompts for both values at enable time, so if
# neither reached the hook, something is wrong. Say so.
status="> ⚠️ Scribe: live context disabled this session — neither the Scribe base URL nor the API key reached this hook. Configure the plugin (\`/plugin\` → Scribe), or export SCRIBE_URL + SCRIBE_TOKEN. Note this also disables prompt auto-inject and the write-path prior-art trigger. Tools still work; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`."
status="> ⚠️ Scribe: live context disabled this session — neither the Scribe base URL nor the API key reached this hook. Configure the plugin (\`/plugin\` → Scribe), or export SCRIBE_URL + SCRIBE_TOKEN. Note this also disables prompt auto-inject and the write-path prior-art trigger. Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
fi
[ -n "$dyn" ] && append "$dyn"
@@ -179,7 +128,7 @@ fi
# Compaction re-grounding: lead with a reload banner when this fire is a compact.
if [ "$source" = "compact" ]; then
prepend "> ⟳ This session was just COMPACTED — earlier turns are now a summary, so in-flight detail may be lost. Any rules that had been retrieved went into that summary with everything else, so treat yourself as holding none: before the next consequential act, ask again with \`search(content_type=\"rule\")\` rather than trusting a half-remembered one. Re-run \`enter_project()\` for the active project, check its open tasks and recent notes, and reconcile what you are mid-way through against what Scribe records. Scribe is the record."
prepend "> ⟳ This session was just COMPACTED — earlier turns are now a summary, so in-flight detail may be lost. Before continuing, reload your bearings from Scribe: re-pull the operator's binding rules with \`list_always_on_rules()\` (a compaction can summarize them out of context, leaving only generic harness defaults in their place), re-run \`enter_project()\` for the active project, check its open tasks and recent notes, and reconcile what you're mid-way through against what Scribe records. Don't trust half-remembered state — Scribe is the record."
fi
# Nothing at all to inject → stay silent.
+5 -23
View File
@@ -6,8 +6,7 @@ of record (notes, tasks, projects, milestones, rules) reachable through the
for the operator's work, and as your own working memory across sessions.
**At the start of this session:**
- You hold none of the operator's rules, and there is no call that loads them
all. Rules arrive when something you are about to do matches one.
- Call `list_always_on_rules()` to load the operator's binding rules.
- If the working repo maps to a Scribe project (check `list_repo_bindings`),
call `enter_project(<id>)` to load that project's rules, open tasks, and
recent notes in one shot.
@@ -18,27 +17,10 @@ for the operator's work, and as your own working memory across sessions.
operator's Scribe rules decide what to do — NOT generic conventions baked
into the harness or your defaults (e.g. "branch before committing," "open a
feature branch per task," "push to a fork"). If you have not loaded the
no rule has arrived for the act in front of you, `search(content_type=
"rule")` BEFORE acting rather than falling back on a default habit. When a
retrieved rule and a default habit disagree, the rule wins; if no rule
speaks to it, ask rather than assume.
- **Rules bind; preferences do not.** A record's `kind` says which. A **rule**
must be followed — ignoring it breaks something or crosses a boundary. A
**preference** is how the operator wants work done: worth following for
consistency, not a defect to miss. Injected lines name the kind in their
opening words. A preference is also yours to keep current when they correct
you (`update_preference`); a rule waits for them.
- **Silence is not absence.** Nothing is preloaded: every rule is RETRIEVED,
when what you are doing resembles what the rule is about. Most turns
retrieve none, and a rule you were never handed binds exactly as hard as one
you were. So before a consequential act, `search` for a rule about it
(`content_type="rule"`) rather than concluding from an empty session that
nothing applies. "I was not told" is not the same as "there is no rule," and
only one of those is checkable.
This bites hardest on which TOOL to reach for — curling an API that has an
MCP client, standing up a local stack, running a suite CI owns. Those feel
like mechanics rather than decisions, so they raise no doubt and generate no
query; the moment you are most confident is the moment to look.
operator's rules this session — or earlier turns were summarized away by a
compaction — call `list_always_on_rules()` (and `enter_project()` when a
project is in scope) BEFORE acting. When a loaded rule and a default habit
disagree, the rule wins; if no rule speaks to it, ask rather than assume.
- **Recall before acting** — before you answer anything about the operator's
work or start a task, `search` Scribe first; assume a related note, task, or
decision already exists. Concretely, reach for recall whenever a request
-117
View File
@@ -1,117 +0,0 @@
#!/usr/bin/env bash
# Scribe — PreToolUse rule arm for ACTIONS (#3476).
#
# The sibling of scribe_prior_art.sh. That hook is registered on Write|Edit and
# asks "what is recorded about the file being written". This one asks "does a
# standing rule speak to the command about to be run" — the question nothing
# could ask before, and the reason every rule about which tool to reach for had
# to live in the preload instead, back when there was one.
#
# WHY A HOOK AND NOT AN INSTRUCTION. A reflex generates no query (note #3089):
# you reach for `curl` confidently, with no moment of doubt, so a surface that
# waits to be asked never fires. Here nothing is asked — the tool call IS the
# query, and the reflex has to become a tool call before it can do anything.
#
# SILENT ON OUTAGE, deliberately, unlike the prior-art hook. A write is
# occasional; a Bash call is not, and an "instance did not answer" line before
# every command is the noise that gets a channel muted. scribe_prior_art.sh
# still speaks for both when the instance is down.
#
# Env:
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
command -v jq >/dev/null 2>&1 || exit 0
command -v curl >/dev/null 2>&1 || exit 0
# PreToolUse delivers { session_id, cwd, tool_name, tool_input: {...}, ... }
event=$(cat 2>/dev/null || true)
tool_name=$(printf '%s' "$event" | jq -r '.tool_name // empty' 2>/dev/null) || exit 0
session_id=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_cwd=""
[ -n "$tool_name" ] || exit 0
# The action, as text. `.command` is Bash's field; the fallbacks let the matcher
# in hooks.json widen to other tools without this script changing — which is the
# whole reason the server side takes a name and a string rather than a schema.
command_text=$(printf '%s' "$event" | jq -r '
.tool_input.command //
.tool_input.url //
.tool_input.prompt //
empty' 2>/dev/null) || command_text=""
[ -n "$command_text" ] || exit 0
# shellcheck source=plugin/hooks/scribe_defs.sh
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
# scribe_config, not a hand-rolled pair of parameter expansions: it also treats
# an UNEXPANDED `${...}` placeholder as unset, which would otherwise be sent as
# a garbage Bearer token and 401 on every call (#2198's class).
scribe_config || exit 0
# Bounded before encoding: a heredoc or a pasted script can be enormous, and
# the verb and its target — the part a rule is about — sit at the front. The
# server bounds it again; this keeps a huge payload off the wire in the first
# place. `head -c`, never `cut -c`: cut truncates each LINE and caps nothing.
command_text=$(printf '%s' "$command_text" | head -c 2000)
# -sRr, never -rR: jq -R without -s reads LINE BY LINE, so a multi-line command
# would encode per line and join with raw newlines — an invalid URL.
cmd_enc=$(printf '%s' "$command_text" | jq -sRr '@uri' 2>/dev/null) || exit 0
tool_enc=$(printf '%s' "$tool_name" | jq -sRr '@uri' 2>/dev/null) || exit 0
repo_q=""
lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
repo_remote=$(git -C "$lookup_dir" remote get-url origin 2>/dev/null || true)
if [ -n "$repo_remote" ]; then
repo_enc=$(printf '%s' "$repo_remote" | jq -sRr '@uri' 2>/dev/null) || repo_enc=""
[ -n "$repo_enc" ] && repo_q="&repo=${repo_enc}"
fi
# THE SHARED SESSION LEDGER, and the thing most worth getting right here.
#
# scribe_prior_art.sh keeps the rules it has already named in
# <state>/<sid>.rules.ids and passes them as exclude_rule_ids. This hook reads
# and appends to that SAME file rather than keeping its own: two ledgers would
# mean a rule named by one arm gets re-offered by the other, and the hint that
# fires most often is exactly the one that must not repeat itself.
#
# The directory keeps the prior-art name on purpose — renaming it would orphan
# every live session's state for a cosmetic gain.
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
mkdir -p "$state_dir" 2>/dev/null || true
rulefile=""
rule_exclude_q=""
if [ -n "$session_id" ]; then
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
rulefile="$state_dir/${safe_sid}.rules.ids"
# Ageing, not a flat read (#3751): an id named two hours ago is not one the
# session is still holding. scribe_rules_live carries the reasoning.
rule_seen=$(scribe_rules_live "$rulefile")
[ -n "$rule_seen" ] && rule_exclude_q="&exclude_rule_ids=${rule_seen}"
fi
# `|| exit 0` here, unlike the prior-art hook: there is no local arm whose
# finding would be discarded, and an outage line before every command is worse
# than silence. See the header.
body=$(curl -fsS --max-time 5 \
-H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/tool-rules?tool=${tool_enc}&command=${cmd_enc}${repo_q}${rule_exclude_q}" 2>/dev/null) || exit 0
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
[ -n "$context" ] || exit 0
# Remember what was named so it is not repeated this session.
if [ -n "$rulefile" ]; then
printf '%s' "$body" | jq -r '(.rule_ids // [])[]?' 2>/dev/null \
| scribe_rules_append "$rulefile"
fi
jq -cn --arg ctx "$context" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
additionalContext: $ctx
}
}' 2>/dev/null || true
exit 0
+27 -137
View File
@@ -1,6 +1,6 @@
---
name: using-scribe
description: Use at the START of every session, and before answering anything about the operator's work or starting any task — establishes the Scribe-first reflex. You hold none of the operator's rules: they arrive by retrieval when your work matches one, and search(content_type="rule") is how you ask before a consequential act. Call enter_project when a repo/project is in scope. Then recall before acting, update over duplicate, plan in Scribe not in files.
description: Use at the START of every session, and before answering anything about the operator's work or starting any task — establishes the Scribe-first reflex. FIRST ACTION of a session: call list_always_on_rules() (and enter_project when a repo/project is in scope) to load the operator's binding rules. Then recall before acting, update over duplicate, plan in Scribe not in files.
---
# Using Scribe
@@ -13,19 +13,12 @@ asked for.
## Do this first (every session)
**You are not holding the operator's rules, and no call loads them all.**
There is no standing set to pull. A rule reaches you when what you are about to
do matches it — a command, code you are writing, or what the operator just
asked for — and on most turns none will. That is the surface working.
**So the reflex is to ASK, not to load.** Before a consequential act — anything
hard to reverse or outward-facing — `search(content_type="rule")` for the thing
you are about to do. An empty session is not evidence of an empty rulebook.
If the working repo maps to a Scribe project (you're in a known repo, or
`list_repo_bindings` shows a binding), call `enter_project(id)` — it returns the
project plus the rules bound to the areas it works in, open tasks, and recent
notes in one shot.
**Pull the standing rules yourself — do not wait for them to be handed to you.**
At the start of a session, before substantive work, call
`list_always_on_rules()` to load the operator's always-on rules. If the working
repo maps to a Scribe project (you're in a known repo, or `list_repo_bindings`
shows a binding), call `enter_project(id)` instead/as-well — it returns the
project plus its applicable rules, open tasks, and recent notes in one shot.
Do this actively. A SessionStart hook *may* also inject a rule index, but treat
that as a bonus, not a precondition: it can be absent (e.g. when the instance is
@@ -63,50 +56,10 @@ Two constraints on *how* that's achieved:
re-deriving it or opening a duplicate. When a project is in scope, pass its
`project_id` so results stay scoped.
2. **Rules are binding, and silence does not mean there are none.** Nothing
is preloaded, so "no rule arrived" means "nothing matched" — never "no rule
exists". Ask with `search(content_type="rule")` before a consequential act,
and pull a record's full statement with `get_rule(id)` when it is about to
bite. When a project is in scope, `enter_project(id)` also returns the rules
bound to its areas.
**`kind` says how much force a record carries, and it is never something to
infer.** A **rule** must be followed: ignoring it breaks something or
crosses a boundary. A **preference** records how the operator wants work
done, and ignoring it costs consistency rather than correctness. Both are
worth following and both arrive the same way; only one is a mistake to
miss. An injected line names which in its opening words — *"Standing rule
that may apply…"* against *"Preference that may apply…"* — and every
payload carries `kind` outright.
**A preference is the one record you keep current yourself.** When the
operator corrects you, or the preference on file no longer matches how they
actually want something done, `update_preference` — that is expected, not a
liberty, and it wants the task or note that taught the change. Say in the
same turn that you did it, so they can disagree while it is in front of
them. A rule waits for the operator instead: `create_rule` proposes and
asks. If what you learned is that something MUST be done a certain way,
that is a rule to propose, not a preference to harden in place.
Rules come in two tiers. **Always-on** rules are delivered — they arrive
whether or not you ask. **Conditional** rules are RETRIEVED, and one binds
just as hard for never having been handed to you. So before a consequential
act, `search(content_type="rule")` on what you are about to do. An empty
loaded set is not evidence that no rule applies; it is only evidence that
none was pushed, and those are different claims.
The tier split exists because delivery does not scale: every resident rule
costs tokens in every session forever, so a rulebook that grows past a few
dozen either stops growing or stops fitting. Retrieval is what lets the
rulebook keep growing — but retrieval only fires if something asks.
**Ask hardest where you feel most certain.** Rules about which TOOL to reach
for — use the forge's MCP client rather than curling its API, don't stand up
a local stack, don't run the suite CI owns — govern moves that feel like
mechanics rather than decisions. A reflex raises no doubt, so it generates
no query, so the rule that would have stopped it is never retrieved. That is
the failure this instruction exists to prevent, and confidence is its only
warning sign.
2. **Standing rules are binding.** Load them via `list_always_on_rules()` at
session start (see "Do this first"); treat every one as binding. Pull a
rule's full statement with `get_rule(id)` when it's about to bite. When a
project is in scope, `enter_project(id)` also returns its applicable rules.
3. **Update over duplicate.** When recording, prefer updating an existing
note/rule/task over creating a new one. Search first; revise what's there.
@@ -143,25 +96,7 @@ Two constraints on *how* that's achieved:
not restraint. Only a record genuinely about no particular area goes
untagged.
8. **Name the record, never just its number.** Whenever you refer to a Scribe
record — in a message to the operator, a commit message, a task body, a
work-log — write the id *and* its title: `#3244 "the staleness signal"`,
`milestone 323 "rule versioning"`. Not `#3244`.
You have the record open; the operator does not. A bare id reads as
complete to you and as homework to them — they have to look it up to know
what their own conversation is about, or guess. Scribe's own duplicate gate
already writes `id 412: "debounce helper"` for exactly this reason; match
it everywhere else.
The first mention in a message carries the title; later mentions of the
same record can use the bare id. If you don't know the title, look it up
before citing the number — an id you can't name is one you haven't checked.
This matters most in the places read later by someone with even less
context than the operator has now: commit messages, task bodies, and any
record that cites another.
9. **State updates in place; chronicles don't.** A dev-log records what
8. **State updates in place; chronicles don't.** A dev-log records what
*happened* — write it once, never rewrite it. A durable finding (how a
subsystem works, a measured number) lives in that System's **reference
note** ("«System» — reference"), which you UPDATE as facts change — safe,
@@ -171,48 +106,6 @@ Two constraints on *how* that's achieved:
re-measurement, a reversed decision), pass the old id in `supersedes` so the
stale record is demoted and labelled rather than left competing.
10. **A few notes assert a FACT, and those can carry their own check.**
Supersession only fires once somebody has read a note and disagreed — which
is the case where it was already believed. A note asserting something about
*someone else's* software — what a service does on a duplicate upload, how a
forge numbers its CI runs, what an updater compares — can instead carry
`verify_with` (how to check it) and `expires_when` (the STATE that ends it:
"when the forge numbers runs per workflow", never "in six months").
`notes_due_for_verification` lists them least-recently-confirmed first, with
never-checked at the top; `mark_note_verified` records what you found, and
`still_true=False` deliberately writes nothing — a note whose check failed
is wrong rather than in a state worth recording, so it keeps its place.
**The test is one question: could this note become false without anyone
editing it?** If no, leave both fields empty. That is the normal case, and
an empty `verify_with` is the positive marker for "this is a decision, there
is nothing to go and check" — not an unfinished record. The sweep is only
worth reading while almost nothing is on it, so a check added out of
tidiness costs the whole surface, not just that note.
**The sharper form of the same test: is the thing this note describes yours
to change?** If yes it is a decision — editing your own software is how it
changes, and you will know you did it. Measured against a real corpus, every
note that earned a check was about somebody ELSE's software: a signing
service, a forge, a hub, an SDK, a model, a dependency set.
**Three that look like candidates and are not:**
- **Resume pointers and "current state" notes.** They go stale fastest of
anything, which is exactly why they tempt — but the cure is to update or
delete them, not to schedule a check. A sweep full of pointers is a sweep
nobody reads.
- **Measurements of your own system.** They go false because you changed
something, and you knew. A measurement earns a check only when what it
measures is outside your control.
- **A decision that RESTS on somebody else's behaviour.** The decision is
still a decision. Put the check on the note asserting the fact, and link
the decision to it.
Not for tasks — a task's decay is its status, and a done issue records what
happened rather than asserting something that can go false. Not for snippets
either: `verify_snippet` compares the recorded location and code against the
repo, which is richer and already wired to drift detection.
## Stay inside the active project's scope
Once a project is in scope — you called `enter_project`, or the working repo is
@@ -230,12 +123,13 @@ bound — confine the session to it:
## Starting a project: decide what it inherits
A project's inheritance is a **decision, not a default**. Before
`create_project`, ask the operator the three inception questions and pass the
`create_project`, ask the operator the four inception questions and pass the
answers — never create a project bare by default:
- which rulebooks to **subscribe** (`list_rulebooks` shows them; default: none
— a rulebook binds a project only when it opts in) →
`subscribe_rulebooks=[...]`
- which **always-on rulebooks** it should NOT inherit (`list_rulebooks` shows
which are always_on; default: inherit them all) →
`exclude_always_on_rulebooks=[...]`
- which other rulebooks to **subscribe**`subscribe_rulebooks=[...]`
- which **design system** its UI is built from (`list_design_systems`; or
none) → `design_system_id=<id | -1>`
- whether to **seed the standard starter Systems** so records can be tagged
@@ -253,23 +147,19 @@ inception is the moment they are decided together, and the record of why.
When codifying a rule, pick its home by **who it should bind** — and keep
shared homes general:
- **Rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a reusable,
*themed* module of general rules that binds the projects which opt in (e.g. a
review checklist → every service). Themed, but project-agnostic.
- **Always-on rulebook** (`create_rule` in an `always_on` rulebook) — universal
norms that bind *every* project. Cross-project standards only.
- **Subscribed rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a
reusable, *themed* module of general rules that binds only projects that opt
in (e.g. a review checklist → every service). Themed, but project-agnostic.
- **Project rule** (`create_project_rule`) — anything specific to one project
(its files, paths, quirks).
There used to be a third home — an `always_on` rulebook that bound every
project automatically. It is gone: subscription is the only reach a rulebook
has. Names one project's specifics → project rule; anything a category of
projects shares → rulebook. Never put project-specific detail in a rulebook —
it leaks to every other project that subscribes.
**Whichever home it gets, a rule needs `when_to_apply`.** It is the only thing
that decides whether the rule is ever seen: nothing is preloaded, so a rule
with no trigger is not a quiet rule, it is an unreachable one. Write the moment
in the words a session actually produces — the command, the error, the
half-formed ask — not the category it belongs to.
Both rulebook tiers are shared, so their rules stay general; they differ in
**reach** (all vs opt-in), not generality. Names one project's specifics →
project rule; a standard a category shares → subscribed rulebook; a universal
norm → always-on rulebook. Never put project-specific detail in a shared
rulebook — it leaks to every other project that gets it.
**First ask whether it's a rule at all.** A rule is prose you have to remember
and apply; Scribe's other entities are structure a tool can resolve and check.
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Bump the patch segment of fable-mcp/pyproject.toml version and stage the file.
# Usage: called automatically by the Claude Code pre-commit hook, or manually.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FILE="$REPO_ROOT/fable-mcp/pyproject.toml"
current=$(grep '^version = ' "$FILE" | sed 's/version = "\(.*\)"/\1/')
major=$(echo "$current" | cut -d. -f1)
minor=$(echo "$current" | cut -d. -f2)
patch=$(echo "$current" | cut -d. -f3)
new_version="$major.$minor.$((patch + 1))"
sed -i "s/^version = \"$current\"/version = \"$new_version\"/" "$FILE"
git -C "$REPO_ROOT" add "$FILE"
echo "fable-mcp: $current$new_version"
+15 -22
View File
@@ -119,20 +119,15 @@ def same_hue_text_on_tint(css: str) -> tuple[list[str], list[str]]:
dangerous of the two — it does not even name a `-bg` token, so nothing
about it looks like the pattern until you measure it.
Returned separately because they were paid down separately — the token
form first (7 badge pairs), then the inline form (46 sites across 18
files, 26 of them --fs-accent). Both are clean now, so BOTH gate. The
split is kept because the two spellings need different error text: one
names a -bg token you can search for, the other names nothing at all.
Returned separately because they are at different stages. The token form
is CLEAN and therefore gates. The inline form has a live backlog (48 sites
when this split was written, 26 of them --fs-accent), so it reports with a
count: a gate nobody can satisfy today gets switched off, and then it
guards nothing.
"""
token_form, inline_form = [], []
for body in re.findall(r"\{([^{}]*)\}", css):
# (?<![-\w]) or `border-color`, `border-left-color` and `outline-color`
# all match as if they were text. They are not: a border is a non-text
# graphic and its floor is 3:1, not 4.5. Without this the check reported
# seven rules that were already correct — and a check that cries wolf on
# correct code is one that gets muted.
fg = set(re.findall(r"(?<![-\w])color\s*:\s*var\(\s*(--fs-[\w-]+?)\s*\)", body))
fg = set(re.findall(r"color\s*:\s*var\(\s*(--fs-[\w-]+?)\s*\)", body))
bg_tok = set(re.findall(r"background(?:-color)?\s*:\s*var\(\s*(--fs-[\w-]+?)-bg\s*\)", body))
bg_inl = set(re.findall(
r"background(?:-color)?\s*:\s*color-mix\([^;]*?var\(\s*(--fs-[\w-]+?)\s*\)[^;]*?\)",
@@ -237,23 +232,21 @@ def main() -> int:
print("OK — no text painted with a token on a tint of its own -bg.\n")
if inline_tint_hits:
print(f"FAIL — {len(inline_tint_hits)} rule(s) paint text with a token on an "
f"INLINE color-mix tint of that same token.")
print(" Identical defect to the block above, spelled without a -bg token —")
print(" which is what let it hide: nothing about it LOOKS like the pattern.")
print(" Use the token's -fg sibling.\n")
for path, tok in inline_tint_hits:
print(f" {path}: color: var({tok}) on an inline tint -> var({tok}-fg)")
print()
else:
print("OK — no text painted with a token on an inline tint of itself.\n")
by_tok: dict[str, int] = {}
for _p, tok in inline_tint_hits:
by_tok[tok] = by_tok.get(tok, 0) + 1
print(f"REPORT — {len(inline_tint_hits)} rule(s) paint text with a token on "
f"an INLINE color-mix tint of that same token.")
print(" Same defect, spelled without a -bg token so it does not gate yet.")
print(" Worst offenders: " + ", ".join(
f"{t} x{n}" for t, n in sorted(by_tok.items(), key=lambda kv: -kv[1])[:4]) + "\n")
if args.report_literals:
print(f"REPORT — {literal_count} raw colour literal(s) in component CSS.")
print(" Advisory: a literal is a value stated outside the system, so it "
"cannot follow a palette change.\n")
return 1 if (unresolved or same_hue_hits or inline_tint_hits) else 0
return 1 if (unresolved or same_hue_hits) else 0
if __name__ == "__main__":
+47 -280
View File
@@ -13,20 +13,9 @@ separate defects have reached a live install through that path:
install, because `plugin.json`'s version wasn't bumped and the installer
compares versions to decide whether to refresh its cache.
Both were fixed. The second was fixed TWICE — once by bumping the number, and
then properly, by removing the class it came from: `plugin.json`'s version is
no longer a value anybody chooses. `scripts/mint_plugin_version.py` derives it
from the clock (`make mint-plugin`), and `check_version_is_minted` below fails
the lane when shipped content moved and the version did not.
State exactly what that did and did not remove, because a rationale that
overstates its own control is how the control gets trusted past its limit, and
because the paragraph this replaces was itself read that way. Gone: having to
remember which NUMBER to write, and the whole question of whether a chosen
number was the right one. Not gone: the mint still has to be RUN, and
forgetting to run it is still possible. What changed is that forgetting is now
LOUD — a red lane on the batch that forgot, instead of a silent no-op found
weeks later when somebody says "I don't think it updated" (#2220).
The rule for the second one was already written down and was still missed. A
written rule that depends on being remembered during a long session is not a
control; this is.
shellcheck and jq are NOT in `ci-python` (verified against CI-runner's Dockerfile
and scripts/install-common.sh, not from memory — rule #37). CI installs both
@@ -43,14 +32,7 @@ whole file exists to prevent.
Usage:
python3 scripts/check_plugin.py # all checks
python3 scripts/check_plugin.py --no-version # on `main` only — see below
`--no-version` exists for ONE case. The version is measured against
`origin/main`, so on `main` itself the comparison is against itself and answers
nothing; the syntax, pattern and marker checks are the only ones that mean
anything there. It is NOT a way past a red lane — see
`check_version_is_minted`, whose whole design is shaped by keeping this flag
out of anyone's muscle memory.
python3 scripts/check_plugin.py --no-version # skip the bump check
"""
from __future__ import annotations
@@ -61,90 +43,16 @@ import re
import shutil
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
# The shape contract is ONE definition, shared with the script that mints it —
# a checker carrying its own copy of the format would drift from the minter
# and pass values the minter can no longer produce. Explicit path insert
# because this file runs both as `python3 scripts/check_plugin.py` (which puts
# `scripts/` on the path, not the root) and as an import from the test suite.
sys.path.insert(0, str(ROOT))
from scripts.mint_plugin_version import VERSION_RE # noqa: E402
PLUGIN_DIR = ROOT / "plugin"
HOOKS_DIR = PLUGIN_DIR / "hooks"
MANIFEST = PLUGIN_DIR / ".claude-plugin" / "plugin.json"
# ── What ships, and what decides what it says about itself ─────────────────
#
# ONE definition (#3127 §3, milestone 334 step 2). It has TWO consumers that
# need different granularities, and conflating them is the bug:
#
# the workflow's `paths:` trigger whole paths should CI run at all?
# the version check paths MINUS should the version
# the manifest have moved?
# `version`
#
# The second one is why this is not just a tuple of paths. `plugin.json` lives
# INSIDE `plugin/`, so a version bump is itself a change to the shipped set —
# and a check that reads the set naively then treats the bump as its own
# justification. Any bump passes, no bump fails, and it has proved nothing.
# `shipped_content_changed` below is the exclusion-aware reader.
#
# The exclusion is that ONE FIELD, never the whole file: `plugin.json` also
# carries description, mcpServers and userConfig, all of which reach an
# install and all of which matter. Excluding the file wholesale would mean a
# userConfig-only edit computes an unchanged version and never refreshes —
# #2209 again with a narrower trigger.
SHIPPED_PATHS = ("plugin", ".claude-plugin")
# Files that decide what a published artifact SAYS ABOUT ITSELF — kept as a
# table so the next artifact is a one-line addition rather than a third
# bespoke guard (#3127 §3). The membership test is NOT "is this copied into
# the artifact?" but "can changing this file change the published bytes, or
# what the artifact says about itself?" — FC learned that twice in four days
# (#3156, #3202), and a deriver is never in the COPY list.
#
# Note what is absent: a CHECKER does not belong here. Whatever validates a
# version decides whether the lane goes red, not what any artifact reports,
# so `check_plugin.py` itself is not a deriver, while the script that mints
# the plugin version is.
DERIVERS: dict[str, tuple[str, ...]] = {
# The "Generate image tags and version" step computes the server image's
# name, ordering key and channel (#3298).
".forgejo/workflows/ci.yml": ("server-image",),
# Decides the plugin's version FORMAT, so it decides what every future
# manifest says about itself (milestone 334 step 3).
"scripts/mint_plugin_version.py": ("plugin",),
}
def version_relevant_paths() -> tuple[str, ...]:
"""Everything a change to which must produce a NEW plugin version.
Wider than `SHIPPED_PATHS`, and #3127 §3's asymmetry is why it has to be:
A change to how the VERSION is computed is compared against nothing at
all. Left out, the published artifact goes on reporting the OLD value
indefinitely.
Concretely — change the mint script's format string, change nothing else,
and a diff over the shipped paths alone reports "no content change, the
version need not move". The manifest then keeps a value in the old format
forever and nothing ever says so. The mint script reaches no install and
belongs here anyway; that is #3156's exact shape.
A CHECKER is deliberately not here. Whatever validates the version decides
whether the lane goes red, not what any artifact reports — so this file is
absent from its own set, and that is not an oversight.
"""
return SHIPPED_PATHS + tuple(
path for path, artifacts in DERIVERS.items() if "plugin" in artifacts
)
# Paths whose contents reach an install. Keep in step with the workflow's
# `paths:` filter — a path that ships but isn't checked here is the gap again.
SHIPPED = ("plugin", ".claude-plugin")
failures: list[str] = []
@@ -235,6 +143,8 @@ def check_patterns() -> None:
ok(f"{rel}: no known-bad patterns")
# --- the version bump ------------------------------------------------------
# --- shellcheck ------------------------------------------------------------
def check_shellcheck() -> None:
@@ -305,16 +215,6 @@ SMOKE_EVENTS: dict[str, str] = {
"tool_input": {"file_path": "src/x.py",
"new_string": f"def {_ABSENT_SYM}():\n pass\n"}}
),
# The pre-tool rule arm (#3476). A real Bash call, and one whose whole
# point is that it looks harmless: reaching for curl against the forge API
# is the reflex the arm exists to catch. With no instance it must stay
# SILENT — it is deliberately not an OUTAGE_SPEAKER, because a Bash call is
# not occasional and an outage line before every command gets the channel
# muted.
"scribe_tool_rules.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
"tool_input": {"command": "curl -s https://example.invalid/api/v1/runs"}}
),
"scribe_sync_processes.sh": json.dumps({"source": "startup"}),
"scribe_session_context.sh": json.dumps({"source": "startup"}),
# The after-write hook (#2901) diffs the working tree; on CI's clean
@@ -491,213 +391,80 @@ def _git(*args: str) -> tuple[int, str]:
return proc.returncode, (proc.stdout or proc.stderr).strip()
def manifest_text(ref: str | None = None) -> str | None:
"""The manifest's RAW TEXT at `ref`, or in the working tree when ref is None.
Split out from `manifest_version` because the exclusion below needs every
field except one, not the one field.
"""
def manifest_version(ref: str | None = None) -> str | None:
"""The manifest version at `ref`, or in the working tree when ref is None."""
if ref is None:
try:
return MANIFEST.read_text()
except OSError:
return json.loads(MANIFEST.read_text()).get("version")
except Exception:
return None
rel = MANIFEST.relative_to(ROOT).as_posix()
code, out = _git("show", f"{ref}:{rel}")
return out if code == 0 else None
def manifest_version(ref: str | None = None) -> str | None:
"""The manifest version at `ref`, or in the working tree when ref is None."""
text = manifest_text(ref)
if text is None:
return None
try:
return json.loads(text).get("version")
except Exception:
return None
# Distinct from None, which is a legitimate "this manifest does not exist".
_UNREADABLE = object()
def manifest_differs_beyond_version(a: str | None, b: str | None) -> bool:
"""Do two `plugin.json` texts differ in anything OTHER than `version`?
THE exclusion, and it is kept pure — no git, no filesystem — because this
is the half worth testing hard and it needs no repository to exercise.
Compares PARSED objects rather than text, so reformatting, key reordering
and whitespace do not read as content changes. `version` is dropped from
both sides; everything else counts, which is what keeps a userConfig-only
or mcpServers-only edit demanding a new version.
Unreadable input answers True. The conservative direction is "demand a new
version": a spurious bump costs one cache refresh, while a missed one is
#2209 — the fix reaches the repo and stops there.
"""
def without_version(text: str | None):
if text is None:
return None
try:
data = json.loads(text)
except Exception:
return _UNREADABLE
if not isinstance(data, dict):
return _UNREADABLE
return {k: v for k, v in data.items() if k != "version"}
left, right = without_version(a), without_version(b)
if left is _UNREADABLE or right is _UNREADABLE:
return True
return left != right
def shipped_content_changed(base: str) -> tuple[bool | None, list[str]]:
"""Has anything that REACHES AN INSTALL changed against `base`?
Returns `(changed, paths)`. `changed` is **None** when the question could
not be answered — a caller must never read that as "no", which is the
distinction #2663 cost weeks of zeroed telemetry to learn.
The manifest is special-cased, not excluded: if it is the ONLY thing that
moved and the only difference is `version`, nothing that reaches an
install has changed. Any other manifest field, or any other file, counts.
Reads `version_relevant_paths`, which is the shipped set PLUS the files
that decide the version — see there for why the deriver has to be in it.
"""
code, out = _git("diff", "--name-only", base, "--", *version_relevant_paths())
if code != 0:
return None, []
paths = [p for p in out.splitlines() if p.strip()]
if not paths:
return False, []
rel_manifest = MANIFEST.relative_to(ROOT).as_posix()
if paths == [rel_manifest]:
return manifest_differs_beyond_version(
manifest_text(), manifest_text(base)
), paths
return True, paths
return None
try:
return json.loads(out).get("version")
except Exception:
return None
def check_version_is_minted(base: str = "origin/main") -> None:
"""THE control (#3127 checklist 4), replacing "somebody remembers".
def check_version_bump(base: str = "origin/main") -> None:
"""If shipped plugin content differs from `base`, the version must too.
The checklist asks, of any hand-set component: *say what happens the
release somebody forgets it.* This is the answer — the lane goes red,
deterministically, because CI can compute whether the value should have
moved. Its predecessor could only ask "did the number move at all", which
any bump satisfied and which therefore proved nothing.
Four verdicts:
content changed, version did not FAIL — this is #2209, exactly
version not in canonical shape FAIL — see below
version implausibly in the future FAIL — a bad clock or a hand-edit
version moved, content did not pass, and say so
THE LAST ROW IS NOT A FAILURE, DELIBERATELY. A needless re-mint costs one
cache refresh and nothing else. Failing the lane over a harmless act is how
a check earns a `--no-version` in somebody's muscle memory and stops
running at all — which is the failure mode this whole file exists to
prevent. The implication that matters is one-directional: content changed
IMPLIES version moved.
A malformed version is worth failing on even though the installer would
accept it. `K4` returns the manifest string verbatim, and `H == "unknown"`
sets `forceOverwrite`, so a broken value either sorts as a normal string
or reinstalls the plugin every single session (#3325). Neither is loud.
Stated against the BASE BRANCH rather than the last commit, as its
predecessor was: a per-commit rule would demand a fresh mint from every
commit in a batch, when what matters is that whatever reaches an install
differs from what is cached. One mint per batch, which is also how a person
would do it.
Stated against the BASE BRANCH rather than the last commit on purpose. A
per-commit rule would demand a bump from every commit in a batch; what
actually matters is that whatever reaches an install carries a version the
installer can tell apart from the one already cached. One bump per batch,
which is also how a human would do it.
"""
code, _ = _git("rev-parse", "--verify", base)
if code != 0:
# Do NOT pass silently — a check that quietly no-ops is how this class
# of bug survives in the first place.
fail(
f"cannot resolve {base}, so the minted-version check could not run. "
f"cannot resolve {base}, so the version-bump check could not run. "
f"Fetch it first — `git fetch --depth=1 origin main:refs/remotes/"
f"origin/main` is enough, since this diffs two trees and needs no "
f"common ancestor — or pass --no-version deliberately."
)
return
here = manifest_version()
code, changed = _git("diff", "--name-only", base, "--", *SHIPPED)
if code != 0:
fail(f"git diff against {base} failed: {changed}")
return
if not changed.strip():
ok(f"no shipped plugin changes against {base} — version bump not required")
return
here, there = manifest_version(), manifest_version(base)
if here is None:
fail(f"could not read a version from {MANIFEST.relative_to(ROOT)}")
return
if not VERSION_RE.match(here):
fail(
f"the manifest version is {here!r}, which is not YYYY.MM.DD.HHMM.\n"
f" One shape for every version in the family (#3127 checklist "
f"10), zero-padded so the midnight case renders 2026.01.05.0000.\n"
f" Run `make mint-plugin`."
)
return
minted = datetime.strptime(here, "%Y.%m.%d.%H%M").replace(tzinfo=timezone.utc)
# A day of slack: the mint happens on a workstation and the lane runs
# later, so a *small* skew is ordinary. A value further out than that is
# a wrong clock or a typed year, and it makes the version lie about when
# it was minted.
if minted > datetime.now(timezone.utc) + timedelta(days=1):
fail(
f"the manifest version {here} is in the future. Either the clock "
f"that minted it is wrong, or it was typed by hand."
)
return
changed, paths = shipped_content_changed(base)
if changed is None:
fail(f"git diff against {base} failed, so the version check could not run")
return
there = manifest_version(base)
if there is None:
ok(f"no manifest on {base} — treating as a new plugin (version {here})")
return
if changed and here == there:
files = "\n ".join(paths)
if here == there:
files = "\n ".join(changed.splitlines())
fail(
f"plugin content changed but the version is still {here}.\n"
f" The installer decides whether to refresh its cache by "
f"comparing this string, so an unchanged version means these edits "
f"reach the repo and stop there — the marketplace clone updates, the "
f"cache that actually executes does not (#2209, #1040, #2220).\n"
f" Run `make mint-plugin`.\n"
f"plugin content changed but the manifest version is still {here}.\n"
f" The installer compares versions to decide whether to refresh "
f"its cache, so an unchanged version means these edits reach the repo "
f"and stop there — the marketplace clone updates, the cache that "
f"actually executes does not (issue #2209).\n"
f" Bump `version` in {MANIFEST.relative_to(ROOT)}.\n"
f" Changed:\n {files}"
)
elif changed:
ok(f"plugin content changed and the version was minted {there} -> {here}")
elif here != there:
# Not a failure — see the docstring. Named rather than silent, because
# the uninteresting cause (minted twice) and the interesting one (the
# version-relevant set is too narrow to see what actually changed)
# produce the same line, and only a person can tell them apart.
ok(
f"the version moved {there} -> {here} with no version-relevant "
f"change — harmless, unless something DID change that the set "
f"cannot see"
)
else:
ok(f"nothing version-relevant changed against {base} — no mint required")
ok(f"plugin content changed and version moved {there} -> {here}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--no-version", action="store_true",
help="skip the minted-version check; for `main`, where "
"it would be measured against itself")
help="skip the manifest version-bump check")
parser.add_argument("--base", default="origin/main",
help="branch the version is measured against")
help="branch the version bump is measured against")
args = parser.parse_args()
if not HOOKS_DIR.is_dir():
@@ -711,7 +478,7 @@ def main() -> int:
check_local_prior_art_needs_no_instance()
check_session_context_reports_its_version()
if not args.no_version:
check_version_is_minted(args.base)
check_version_bump(args.base)
print()
if failures:
-144
View File
@@ -1,144 +0,0 @@
#!/usr/bin/env python3
"""Mint the plugin's version — `YYYY.MM.DD.HHMM`, UTC, zero-padded.
Run this whenever you change something under `plugin/` or `.claude-plugin/`,
before you commit:
make mint-plugin # or: python3 scripts/mint_plugin_version.py
WHY A SCRIPT AND NOT A BUILD STEP. `plugin/` is not in the Docker image.
Installs fetch it straight from this git repo via `.claude-plugin/
marketplace.json`, so **a push IS the release** — there is no build between
you committing and a user fetching, and therefore no moment at which CI could
stamp a version in. Every other artifact in the family derives its version
during a build (note #3127 §2). This one has no build to derive during.
WHICH CLOCK, AND WHY IT DIFFERS FROM THE SERVER IMAGE — the divergence is
deliberate, and it lives one directory away from its opposite, so it is
exactly what a later "let's make these consistent" change would collapse:
server image name from COMMIT time, ordering key from BUILD time
(two lanes building one source must report one string;
a rebuild of an older commit must not go backwards)
plugin one value, from MINT time
§2's reason for commit time is that two lanes build one source. The plugin has
one lane and no build, so that reason does not reach it and paying its cost
buys nothing. What is given up is reproducibility-from-history: you cannot
recompute this value later, only verify that it moved when it had to.
That trade is acceptable ONLY because of what #3325 established by reading the
installer's code: the refresh test is `P.version === H`, plain string
equality, with no ordering comparison anywhere. Where a comparator ORDERS, an
unreproducible version is dangerous — nothing can check it is right. Where it
only tests equality, "did it change when it should have" is the entire
specification, and `check_plugin.py` checks that completely.
The manifest is rewritten with a surgical replacement of the `version` line
rather than `json.dump`, because its formatting and key order are not this
script's to decide and a whole-file reformat would make every mint an
unreadable diff.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "plugin" / ".claude-plugin" / "plugin.json"
# Four dot-separated numeric fields, zero-padded, and nothing else — one shape
# for every human-readable version in the family (#3127 checklist 10). The
# padding is load-bearing for the midnight case the checklist names by hand:
# 2026.01.05.0000, which an unpadded `%-H%M` would render as `0` and silently
# shorten. Harmless while nothing orders these, wrong the moment anything does.
VERSION_RE = re.compile(r"^\d{4}\.\d{2}\.\d{2}\.\d{4}$")
VERSION_FORMAT = "%Y.%m.%d.%H%M"
# The `version` line, captured so its surroundings survive byte-for-byte.
VERSION_LINE_RE = re.compile(r'^(\s*"version"\s*:\s*")([^"]*)(".*)$', re.M)
def mint(now: datetime | None = None) -> str:
"""The version for this moment. UTC, always.
The conversion is not decoration: `strftime` renders whatever offset the
datetime carries, so without it two people minting the same instant in
different zones produce different strings — and the string IS the
artifact's identity. A naive datetime is read as UTC rather than as the
machine's zone, because that is this function's stated contract and
guessing the host's offset is how the bug comes back by another route.
"""
moment = now or datetime.now(timezone.utc)
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
return moment.astimezone(timezone.utc).strftime(VERSION_FORMAT)
def rewrite(text: str, version: str) -> str:
"""`text` with its `version` value replaced, and everything else untouched.
Raises rather than falling back to a JSON round-trip: a manifest this
cannot match is one whose shape changed, and quietly reformatting the file
to cope would be a much larger edit than the caller asked for.
"""
# Counted BEFORE substituting, not via subn's return: a capped `subn`
# reports the replacements it made, so a manifest with two `version` lines
# would look like a clean single match while the second one — the real one,
# perhaps — kept its old value.
matches = VERSION_LINE_RE.findall(text)
if len(matches) != 1:
raise ValueError(
f"expected exactly one `version` line in the manifest, found {len(matches)}"
)
return VERSION_LINE_RE.sub(
lambda m: f"{m.group(1)}{version}{m.group(3)}", text, count=1
)
def main() -> int:
parser = argparse.ArgumentParser(description="Mint the plugin's version.")
parser.add_argument(
"--check", action="store_true",
help="print the version that WOULD be minted and change nothing",
)
args = parser.parse_args()
version = mint()
if args.check:
print(version)
return 0
try:
text = MANIFEST.read_text()
except OSError as exc:
print(f"cannot read {MANIFEST.relative_to(ROOT)}: {exc}", file=sys.stderr)
return 1
try:
previous = json.loads(text).get("version")
except Exception:
previous = None
if previous == version:
# Same minute. Not an error — the value is already correct for now, and
# failing here would turn "I ran it twice" into a problem to solve.
print(f"plugin version already {version} (same minute) — unchanged")
return 0
try:
MANIFEST.write_text(rewrite(text, version))
except ValueError as exc:
print(f"{MANIFEST.relative_to(ROOT)}: {exc}", file=sys.stderr)
return 1
print(f"plugin version {previous} -> {version}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Claude Code PreToolUse hook for Bash.
# Reads the tool input JSON from stdin; if the command is a git commit
# and fable-mcp files (other than pyproject.toml) are staged, bumps
# the fable-mcp patch version before the commit proceeds.
#
# Exits 0 always so it never blocks the commit.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
input=$(cat)
command=$(echo "$input" | python3 -c "
import sys, json
data = json.load(sys.stdin)
# Claude Code sends {tool_input: {command: ...}}
ti = data.get('tool_input', data)
print(ti.get('command', ''))
" 2>/dev/null || echo "")
# Only act on git commit commands
if ! echo "$command" | grep -qE "git commit"; then
exit 0
fi
cd "$REPO_ROOT"
# Check if fable-mcp files other than pyproject.toml are staged
fable_staged=$(git diff --cached --name-only 2>/dev/null \
| grep "^fable-mcp/" \
| grep -v "^fable-mcp/pyproject.toml$" \
|| true)
if [ -n "$fable_staged" ]; then
bash "$REPO_ROOT/scripts/bump_fable_mcp_version.sh"
fi
exit 0
+6 -54
View File
@@ -30,46 +30,6 @@ from quart import Quart
# duplicate gate, the untagged-record systems_hint) act in-band in tool
# responses, at the moment they apply.
# Grow one of those, not this block.
# BUDGET: ~1980 of the client's ~2048-char cap (#2562). Everything below is
# competing for the last ~68 characters, so an addition here is a trade, never
# an append.
#
# Milestone 317 (a note's own verify_with / expires_when, and the sweep over
# them) was DECLINED a line, deliberately, by the operator — not overlooked.
# The reasoning, so it is not re-litigated blind: this is a map, and its own
# closing line says each tool's description carries the full contract. The
# sweep is a curation act, not a session-start reflex like enter_project.
# Spending the last of the budget on it would leave the
# map unable to grow for something more central later.
#
# The accepted cost: an agent that never opens create_note's docstring never
# learns the field exists. Guidance lives in the create_note / update_note
# docstrings and the using-scribe skill instead.
#
# Milestone 333 step 3 (2026-09-04) bought the HOW bullet's second clause —
# search(content_type="rule") before a consequential act — by TRADING OUT
# "Processes are saved procedures (follow verbatim)" and "Deletes are
# trash-recoverable". Recorded so the trade is not silently reversed:
# - Both were already in test_instruction_surfaces_agree's DISPLACED_TOPICS
# and already stated on a delivered surface, so nothing fell off: the
# process reflex is in every scribe-proc-* skill listing (each says the
# process governs and is followed verbatim), and trash recovery is in the
# delete_*/list_trash/restore docstrings, which is where per-tool guidance
# belongs by this block's own doctrine.
# - What it bought is not per-tool guidance and has nowhere else to live at
# session-start altitude. Rules were retrievable only by RESIDENCY: the
# always-on preload put them in front of the agent, and nothing told a
# session to go looking for one it had not been handed. That preload is
# gone (milestone 394), which makes this line LOAD-BEARING rather than
# supplementary: retrieval is now the only delivery, and retrieval fires
# only if something asks. A session that waits to be handed a rule is
# handed nothing. A tool-choice reflex asks least of all (#3476, #161).
# - It also has to carry what absence MEANS. "No rule arrived" is now the
# ordinary state rather than the exceptional one, and reading it as
# "there is no rule" is the #3720 defect at session scale. Rule 119 makes
# these surfaces the
# specification, so the same sentence lands on all three session-start
# surfaces, and test_instruction_surfaces_agree pins it.
_INSTRUCTIONS = """
Scribe is the operator's self-hosted second brain and system of record — and
yours: recall from it before acting, record as you go. Keep no parallel copy
@@ -77,8 +37,8 @@ in local files (CLAUDE.md, auto-memory); Scribe holds the single copy.
Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
- ORIENT: enter_project(id) at session start — rules, open tasks, recent
notes, Systems, design system. `inception`: ask what the project
inherits, then decide_project_inception.
notes, Systems, design system. `inception` key: ask what the project
inherits, decide_project_inception (create_project takes the same).
- DO: create_task. Fixed a problem? kind="issue" (symptom -> root cause ->
fix), never a work-log line on an unrelated task. Log with add_task_log;
keep status honest — in_progress on start, done on finish.
@@ -88,14 +48,13 @@ Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
active project_id to stay in scope.
- WHERE work happens: Systems. Tag records with system_ids as you write;
create_system when the area is unmodelled.
- HOW: rules bind; preferences guide. Nothing preloads — a rule arrives
when your work matches it. Before a consequential act,
search(content_type="rule"); silence means nothing matched, not none.
- HOW: rules are binding — list_always_on_rules() at session start.
- UI: the project's design system is binding — resolve_design_system /
get_design_system_stylesheet before hand-writing a value.
- REUSE: search snippets before writing a helper; record what you build with
create_snippet; classify shapes against canon (classify_shapes) — a
consumer map is rows, never prose.
consumer map is rows, never prose. Processes are saved procedures (follow
verbatim). Deletes are trash-recoverable.
A task is a note with status (*_note vs *_task tools).
Creates are duplicate-gated: a near-match BLOCKS and returns the existing
@@ -130,7 +89,7 @@ _READ_ONLY_TOOLS = frozenset({
"get_task", "get_milestone", "get_recent", "enter_project",
"list_milestones", "list_notes", "list_projects", "list_rulebooks",
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
"search",
"list_always_on_rules", "search",
"get_system", "list_systems", "list_system_records",
# The global area catalog and its mapping REPORT — propose writes nothing;
# map_system_to_canonical is the separate, explicitly-called write.
@@ -161,13 +120,6 @@ _READ_ONLY_TOOLS = frozenset({
# prefix, so the completeness test below cannot derive it — the same
# reason `enter_project` is spelled out above.
"retrieval_telemetry",
# The note staleness sweep (milestone 317). A pure read — mark_note_verified
# is the write, and it is deliberately NOT here. Spelled out for
# retrieval_telemetry's reason: `notes_due_for_verification` matches none of
# the prefixes the completeness test derives from, so nothing would have
# prompted this decision. `rules_due_for_verification` is in the same
# position and is NOT listed — see #3191.
"notes_due_for_verification",
})
# Read-SHAPED tools that must NOT be reachable with a read key — a getter that
+3 -15
View File
@@ -57,7 +57,7 @@ async def get_milestone(milestone_id: int) -> dict:
return {
"milestone": out,
"steps": [t.to_dict() for t in steps],
**rulebooks_svc.rules_payload(applicable, user_id=uid, source="get_milestone"),
**rulebooks_svc.rules_payload(applicable),
}
@@ -137,23 +137,11 @@ async def delete_milestone(milestone_id: int) -> dict:
"""Move a milestone to the trash (recoverable). Its tasks go with it as one batch.
Restore via restore(batch_id)."""
uid = current_user_id()
# Read the title BEFORE the delete: afterwards the row is trashed and the
# confirmation could only echo the number back. A deletion the operator
# cannot recognise is one they cannot tell was the wrong one.
# Fail-open: the title is a COURTESY on top of the delete, so a lookup
# that errors must not stop the delete happening. Same posture the
# staleness marker takes — a decoration may never break its payload.
try:
doomed = await milestones_svc.get_milestone(uid, milestone_id)
title = getattr(doomed, "title", "") if doomed else ""
except Exception:
title = ""
batch = await trash_svc.delete(uid, "milestone", milestone_id)
if batch is None:
raise ValueError(f"milestone {milestone_id} not found")
return {"deleted": milestone_id, "title": title, "deleted_batch_id": batch,
"message": f'Milestone {milestone_id} ("{title}") and its tasks '
f"moved to trash. Restore with restore('{batch}')."}
return {"deleted_batch_id": batch,
"message": f"Milestone {milestone_id} + its tasks moved to trash. Restore with restore('{batch}')."}
def register(mcp) -> None:
+3 -168
View File
@@ -103,8 +103,6 @@ async def create_note(
project_id: int = 0,
system_ids: list[int] | None = None,
supersedes: list[int] | None = None,
verify_with: str = "",
expires_when: str = "",
force: bool = False,
) -> dict:
"""Create a new note in Scribe.
@@ -136,33 +134,6 @@ async def create_note(
arrives labelled when it does surface. This records a CLAIM, not a
verdict: it never says the older note was wrong, only that it is no
longer the current answer.
verify_with: HOW TO CHECK this note is still true. Leave empty for
almost every note — that is the normal case, not an unfinished
one.
A note is a NORM or a CONSTRAINT. A norm is a decision ("we derive
versions from commit time"): it has no truth value and changes only
when its author changes it, which they know they did. A CONSTRAINT
asserts a fact about someone else's software ("AMO refuses to
re-sign a version", "this forge numbers CI runs per repository"),
and it goes false with nobody watching. Only constraints get a
check.
The test, in one question: COULD THIS NOTE BECOME FALSE WITHOUT
ANYONE EDITING IT? If no, leave this empty. Sharper still: is the
thing this note describes YOURS TO CHANGE? If yes it is a
decision. Measured against a real corpus, every note that earned a
check was about somebody else's software.
Three that look like candidates and are not: a resume pointer or
"current state" note (goes stale fastest, but the cure is to
update it, not to check it); a measurement of your own system (it
goes false because you changed something, and you knew); and a
decision that RESTS on someone else's behaviour (check the note
asserting the fact, not the decision).
A command, a path, a URL, a query. Prose is allowed; something
runnable is better.
expires_when: The STATE that ends it — deliberately not a date.
"When Forgejo issues run numbers per workflow rather than per
repository", not "in six months". Constraints expire when the
ground moves, not on a schedule.
force: Bypass the near-duplicate gate. By default, if a title- or
meaning-similar note already exists in the same project, creation is
BLOCKED and the existing note's id is returned so you update it
@@ -190,8 +161,6 @@ async def create_note(
body=body,
tags=tags,
project_id=project_id or None,
verify_with=verify_with,
expires_when=expires_when,
)
if system_ids:
await systems_svc.set_record_systems(uid, note.id, system_ids)
@@ -216,9 +185,6 @@ async def update_note(
project_id: int = 0,
system_ids: list[int] | None = None,
supersedes: list[int] | None = None,
verify_with: str = "",
expires_when: str = "",
clear: list[str] | None = None,
) -> dict:
"""Update an existing Scribe note. Only explicitly provided fields are changed.
@@ -233,27 +199,6 @@ async def update_note(
supersedes: Replace the ids of earlier notes this one replaces
(set-semantics). None = leave unchanged; [] = clear all. See
create_note for when to reach for it.
verify_with: How to check this note is still true. Almost every note
should leave this empty — that is the normal, finished state, not
a gap: an empty check is the marker for "this is a decision, there
is nothing to go and check". See create_note for the full
norm-vs-constraint test; the short form is "could this become
false without anyone editing it?".
expires_when: The STATE that ends it, not a date.
clear: Names of fields to UNSET — "verify_with", "expires_when".
Needed because "" means "leave this alone" here, so there is no
value that removes a field: an agent updating a body must not
silently wipe a check it was not asked about. A note that stops
being a constraint is cleared by naming the field, which cannot
happen by accident.
Rewriting `verify_with` drops the note's verification stamp: a stamp
certifies a particular check, and carrying it across a rewrite would vouch
for something nobody has looked at.
A task and a snippet are both REFUSED a check, with a message saying where
to go instead — a task's decay is its status, and a snippet has
verify_snippet.
"""
uid = current_user_id()
fields: dict = {}
@@ -265,13 +210,7 @@ async def update_note(
fields["tags"] = tags
if project_id:
fields["project_id"] = project_id
if verify_with:
fields["verify_with"] = verify_with
if expires_when:
fields["expires_when"] = expires_when
note = await notes_svc.update_note(
uid, note_id, clear=clear or (), **fields
)
note = await notes_svc.update_note(uid, note_id, **fields)
if note is None:
raise ValueError(f"note {note_id} not found")
if system_ids is not None:
@@ -324,113 +263,11 @@ async def find_duplicate_records(kind: str = "note", threshold: float = 0.0) ->
async def delete_note(note_id: int) -> dict:
"""Move a Scribe note to the trash (recoverable). Restore via restore(batch_id)."""
uid = current_user_id()
# Read the title BEFORE the delete: afterwards the row is trashed and the
# confirmation could only echo the number back. A deletion the operator
# cannot recognise is one they cannot tell was the wrong one.
# Fail-open: the title is a COURTESY on top of the delete, so a lookup
# that errors must not stop the delete happening. Same posture the
# staleness marker takes — a decoration may never break its payload.
try:
loaded = await notes_svc.get_note_for_user(uid, note_id)
title = getattr(loaded[0], "title", "") if loaded else ""
except Exception:
title = ""
batch = await trash_svc.delete(uid, "note", note_id)
if batch is None:
raise ValueError(f"note {note_id} not found")
return {"deleted": note_id, "title": title, "deleted_batch_id": batch,
"message": f'Note {note_id} ("{title}") moved to trash. '
f"Restore with restore('{batch}')."}
async def notes_due_for_verification(
older_than_days: int = 0, project_id: int = 0, never_only: bool = False,
) -> dict:
"""Which notes assert a FACT that nobody has confirmed lately.
A corpus of notes holds two kinds of thing. Most are DECISIONS or records
of what happened — they have no truth value and cannot rot. A few assert a
fact about someone else's software: what a signing service does on a
duplicate upload, how a forge numbers its CI runs, what an updater
compares. Those go false silently, with nobody present, and a
cross-project reference note keeps being read as current by every project
that cites it.
This lists the second kind, oldest verification first, NEVER-CHECKED AT
THE TOP — a note nobody has ever confirmed is a claim with no evidence
behind it at all. Each row carries `verify_with` in full, because you are
about to go and run it, plus `expires_when` and `days_since_verified`.
Reach for it when curating, when a note's claim just contradicted what you
observed, or periodically. Then, per row: run the check, and call
mark_note_verified with what you found.
Notes with no `verify_with` never appear, and that is correct — they are
decisions, and there is nothing to go and check. Do not "fix" their
absence by giving them checks: this list is only worth reading while
everything on it genuinely can go false.
Args:
older_than_days: only notes last verified longer ago than this.
Never-checked notes always qualify — they are the most overdue
thing there is. 0 = no age filter.
project_id: narrow to one project. 0 = every project. Unlike the rules
sweep, this filter is safe: a note belongs to at most one project
outright, with none of the subscription and always-on paths that
would make a project filter UNDER-report a rule.
never_only: only notes nobody has ever verified.
"""
uid = current_user_id()
notes = await notes_svc.notes_due_for_verification(
uid,
older_than_days=older_than_days,
project_id=project_id or None,
never_only=never_only,
)
return {
"notes": [notes_svc.verification_row(n) for n in notes],
"total": len(notes),
}
async def mark_note_verified(note_id: int, still_true: bool = True) -> dict:
"""Record that you ran a note's check — and what it said.
Call this AFTER actually running the note's `verify_with`, never on the
strength of the claim sounding plausible. A stamp nobody earned is worse
than no stamp: it moves the note to the bottom of the sweep and buys the
claim another long silence.
`still_true=False` writes NOTHING. A note whose check failed is not in a
special state to be recorded — it is WRONG, and the honest next moves are
to correct it, supersede it, or find out why. So it stays at the top of
the sweep until someone deals with it, and the response tells you what the
note said would end it.
Args:
note_id: the note whose check you ran.
still_true: True if the check passed. False if the fact it asserts is
no longer true — say so, that is the outcome worth having.
"""
uid = current_user_id()
note = await notes_svc.mark_note_verified(note_id, uid, still_true)
if note is None:
raise ValueError(
f"note {note_id} not found, not writable by you, or carries no "
f"verify_with (nothing to verify is not the same as verified)"
)
data = notes_svc.verification_row(note)
data["verified"] = bool(still_true)
if not still_true:
data["next"] = (
"This note is no longer true and is still being read as current "
"by anything that cites it. Correct it with update_note, write "
"the replacement with create_note(supersedes=[...]), or clear its "
"check with update_note(clear=[\"verify_with\"]) if it has stopped "
"asserting a fact at all. It stays at the top of "
"notes_due_for_verification until one of those happens."
)
return data
return {"deleted_batch_id": batch,
"message": f"Note {note_id} moved to trash. Restore with restore('{batch}')."}
def register(mcp) -> None:
@@ -441,7 +278,5 @@ def register(mcp) -> None:
update_note,
find_duplicate_records,
delete_note,
notes_due_for_verification,
mark_note_verified,
):
mcp.tool(name=fn.__name__)(fn)
+13 -11
View File
@@ -207,7 +207,7 @@ async def enter_project(project_id: int) -> dict:
],
"design_system": design_system,
"milestone_summary": milestone_summary,
**rulebooks_svc.rules_payload(applicable, user_id=uid, source="enter_project"),
**rulebooks_svc.rules_payload(applicable),
"open_tasks": [
{
"id": t.id, "title": t.title, "status": t.status,
@@ -251,21 +251,22 @@ async def get_project(project_id: int) -> dict:
applicable = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=uid,
)
data.update(rulebooks_svc.rules_payload(applicable, user_id=uid, source="get_project"))
data.update(rulebooks_svc.rules_payload(applicable))
return data
def _inception_choices(
subscribe_rulebooks, design_system_id, seed_systems,
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
) -> dict | None:
"""The tool args → an inception choices object, or None when no inception
arg was given at all (a bare create stays undecided and enter_project
asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
system."""
if (subscribe_rulebooks is None
if (exclude_always_on_rulebooks is None and subscribe_rulebooks is None
and not design_system_id and seed_systems is None):
return None
return {
"exclude_always_on_rulebooks": list(exclude_always_on_rulebooks or []),
"subscribe_rulebooks": list(subscribe_rulebooks or []),
"design_system_id": None if design_system_id in (0, -1) else design_system_id,
"seed_systems": bool(seed_systems),
@@ -278,6 +279,7 @@ async def create_project(
goal: str = "",
status: str = "active",
color: str = "",
exclude_always_on_rulebooks: list[int] | None = None,
subscribe_rulebooks: list[int] | None = None,
design_system_id: int = 0,
seed_systems: bool | None = None,
@@ -297,10 +299,9 @@ async def create_project(
goal: The desired outcome or definition of done for the project.
status: one of active (default), paused, completed, archived.
color: Optional hex colour for the project card (e.g. "#6366f1").
subscribe_rulebooks: rulebook ids this project opts into. Since
milestone 394 subscription is the only way a rulebook binds a
project, so there is no automatic tier left to decline. Was
exclude_always_on_rulebooks: always-on rulebook ids this project does
NOT inherit ([] = inherit them all). list_rulebooks shows which are
always_on.
subscribe_rulebooks: rulebook ids to subscribe (the non-always-on ones).
design_system_id: the design system this project's UI is built from
(list_design_systems); -1 = explicitly none; 0 = not stated.
@@ -318,7 +319,7 @@ async def create_project(
)
data = project.to_dict()
choices = _inception_choices(
subscribe_rulebooks, design_system_id, seed_systems,
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
)
if choices is not None:
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
@@ -335,6 +336,7 @@ async def create_project(
async def decide_project_inception(
project_id: int,
exclude_always_on_rulebooks: list[int] | None = None,
subscribe_rulebooks: list[int] | None = None,
design_system_id: int = 0,
seed_systems: bool | None = None,
@@ -343,11 +345,11 @@ async def decide_project_inception(
or re-decide later (milestone 297).
Owner-only. Applies the effects through the ordinary tools' paths —
subscribe_project_to_rulebook,
exclude_always_on_rulebook, subscribe_project_to_rulebook,
set_project_design_system, the standard Systems seed — and writes the
decision on the project last, so get_project/enter_project can say why
the project has the rules, design and Systems it has. Re-deciding is
additive for subscriptions (use
additive for exclusions/subscriptions (use include_always_on_rulebook /
unsubscribe_project_from_rulebook to undo one), replaces the design
system, and never re-seeds Systems a project already has.
@@ -357,7 +359,7 @@ async def decide_project_inception(
"""
uid = current_user_id()
choices = _inception_choices(
subscribe_rulebooks, design_system_id, seed_systems,
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
) or {}
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
return {"project_id": project_id, **decided}
+134 -418
View File
@@ -18,7 +18,6 @@ from scribe.mcp._context import current_user_id
from scribe.services import dedup as dedup_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import trash as trash_svc
from scribe.services.rule_usage import record_rule_pulled
# ── Rulebook CRUD ───────────────────────────────────────────────────────
@@ -48,13 +47,16 @@ async def get_rulebook(rulebook_id: int) -> dict:
async def create_rulebook(title: str, description: str = "") -> dict:
"""Create a new rulebook (a shared, reusable module of general rules).
A rulebook reaches a project ONE way: the project subscribes to it
(subscribe_project_to_rulebook). There was a second until milestone 394 —
an `always_on` flag that bound every project automatically — and it is
gone with the tier it belonged to. Opt-in is now the whole model, so a
rulebook binds what asked for it and nothing else.
A rulebook is SHARED, so its rules must stay general — agnostic
Two ways a rulebook reaches projects, set by its always_on flag (toggle via
update_rulebook):
- always_on = true -> binds EVERY one of your projects automatically.
Use for universal cross-project norms that apply across every
project, not just one.
- always_on = false -> binds only projects that subscribe
(subscribe_project_to_rulebook). Use for a THEMED body of rules a
category of projects shares (e.g. a design system that visual apps
opt into).
Either way a rulebook is SHARED, so its rules must stay general — agnostic
to any single project. Project-specific rules go in create_project_rule.
Args:
@@ -70,6 +72,7 @@ async def create_rulebook(title: str, description: str = "") -> dict:
async def update_rulebook(
rulebook_id: int, title: str = "", description: str = "",
always_on: bool | None = None,
) -> dict:
"""Update an existing rulebook. Only non-empty fields are changed.
@@ -77,6 +80,9 @@ async def update_rulebook(
rulebook_id: Rulebook to update.
title: New title. Empty string leaves unchanged.
description: New description. Empty string leaves unchanged.
always_on: When True, rules in this rulebook are loaded at session
start by list_always_on_rules regardless of project context.
Pass None to leave unchanged.
"""
uid = current_user_id()
fields: dict = {}
@@ -84,6 +90,8 @@ async def update_rulebook(
fields["title"] = title
if description:
fields["description"] = description
if always_on is not None:
fields["always_on"] = always_on
rb = await rulebooks_svc.update_rulebook(rulebook_id, uid, **fields)
if rb is None:
raise ValueError(f"rulebook {rulebook_id} not found")
@@ -114,9 +122,8 @@ async def delete_rulebook(rulebook_id: int, confirmed: bool = False) -> dict:
"confirmed_required": True,
}
batch = await trash_svc.delete(uid, "rulebook", rulebook_id)
return {"deleted": rulebook_id, "title": rb.title, "deleted_batch_id": batch,
"message": f'Rulebook {rulebook_id} ("{rb.title}") moved to trash. '
f"Restore with restore('{batch}')."}
return {"deleted": rulebook_id, "deleted_batch_id": batch,
"message": f"Moved to trash. Restore with restore('{batch}')."}
# ── Topic CRUD ─────────────────────────────────────────────────────────
@@ -185,9 +192,8 @@ async def delete_topic(topic_id: int, confirmed: bool = False) -> dict:
"confirmed_required": True,
}
batch = await trash_svc.delete(uid, "topic", topic_id)
return {"deleted": topic_id, "title": topic.title, "deleted_batch_id": batch,
"message": f'Topic {topic_id} ("{topic.title}") moved to trash. '
f"Restore with restore('{batch}')."}
return {"deleted": topic_id, "deleted_batch_id": batch,
"message": f"Moved to trash. Restore with restore('{batch}')."}
# ── Rule CRUD ──────────────────────────────────────────────────────────
@@ -225,6 +231,40 @@ async def list_rules(
return {"rules": [_rule_summary(r) for r in rows], "total": len(rows)}
async def list_always_on_rules(project_id: int = 0) -> dict:
"""Return all rules from rulebooks flagged always_on for the current user.
Call this at session start. Treat the returned rules as binding for the
session — they apply regardless of which project (if any) is in scope.
Returns the ALWAYS-ON tier only (milestone 307). A `conditional` rule is
still binding when it applies; it just is not resident — it reaches a
session through enter_project (when the project works in an area the rule
is tagged to) or through search(content_type="rule"). Nothing here is a
behaviour change until rules are actually re-tiered: `tier` defaults to
always_on, so an existing rulebook returns exactly what it always did.
Pair with get_project(id).applicable_rules when working on a specific
project to also load that project's subscription-derived rules.
A rule carrying `last_verified` asserts a FACT about something outside the
operator's control — a runner's shell, a tool's existence, a setting
somewhere. It is still binding; the field says how long ago anyone
confirmed it, and "never" means nobody has. Follow the rule, and if you
are already standing where the check could be made, make it: get_rule
gives you its `verify_with`. Most rules have no such field, which means
they are decisions and there is nothing to check.
Args:
project_id: 0 (default) = the user-wide set. Inside a project, pass
its id: an always-on rulebook the project EXCLUDED at inception
(see enter_project's `excluded_always_on`) is left out — the
project decided not to inherit it.
"""
uid = current_user_id()
rules = await rulebooks_svc.list_always_on_rules(uid, project_id=project_id)
return {"rules": [_rule_summary(r) for r in rules], "total": len(rules)}
async def get_rule(rule_id: int) -> dict:
"""Fetch a rule by id — full statement + why + how_to_apply.
@@ -237,79 +277,20 @@ async def get_rule(rule_id: int) -> dict:
rule = await rulebooks_svc.get_rule(rule_id, uid)
if rule is None:
raise ValueError(f"rule {rule_id} not found")
# THE pull that matters. The write-path rule arm's own message ends "Read
# it with get_rule(N)", so this is the exact action the hint asks for and
# the only evidence that one landed. Recorded after the access check, so a
# refused read is not counted as a pull.
record_rule_pulled(user_id=uid, rule_id=int(rule.id), source="mcp_get_rule")
return await rulebooks_svc.rule_detail(uid, rule)
async def create_rule(
topic_id: int, title: str, statement: str, when_to_apply: str = "",
why: str = "", how_to_apply: str = "", order_index: int = 0,
tier: str = "always_on", system_ids: list[int] | None = None,
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
system_ids: list[int] | None = None, force: bool = False,
force: bool = False,
) -> dict:
"""Create a new rule in a rulebook (a SHARED rule — keep it general).
PROPOSE RULES READILY, AND WRITE ONE WHEN THE OPERATOR SAYS YES. Noticing
that something has hardened into a standing instruction is valuable work,
and a session that notices it and says nothing has thrown the observation
away. So raise it whenever you see one. The single step that belongs
between noticing and writing is the operator's yes: a rule binds every
future session, and they are the person it binds.
Their yes is also the only moment the rule is reliably IN FRONT of them.
After the write it may not be again for months — a conditional rule is not
read aloud at session start, and a project-scoped one does not appear in
an unfiltered list_rules() at all. So the proposal is the review.
When the operator asks for a rule in so many words, that IS the yes —
write it and move on. The loop below is for the rule you thought of.
A PROPOSAL CARRIES FOUR THINGS, and the fourth is the one that decides it:
1. WHAT it would require — the statement, in the words it would carry,
not a gloss of them. The operator is agreeing to text.
2. INTENT — what it changes about how work gets done, and what goes
wrong today without it. "Be careful about X" is not an intent; the
behaviour that would differ tomorrow is.
3. WHY NOW — the incident, observation or decision behind it. Pass that
record as arose_from_id, and say it in the conversation too: the
field is for the reader six months out, the sentence is for the
person deciding.
4. HOW IT WOULD BE ENFORCED — a test, a CI check, a hook, a schema
constraint, a duplicate gate, a review step... or nothing, in which
case say so plainly: "nothing — this is prose a session has to
remember." Answer this one honestly and it will sometimes dissolve
the rule, which is the point rather than a side effect. What a test
can assert should BE that test; a rule is what remains when nothing
mechanical can hold the thing. A rulebook grows by default and
shrinks only on purpose, so a question that prevents a rule is worth
more than any question that improves one's wording.
THEN CLOSE WITH A QUESTION THEY CAN ANSWER IN ONE WORD. Offer three
answers, and make the middle one the easy one:
* "Approve it AS WRITTEN" — you create it with the statement exactly as
shown. This is what makes element 1 load-bearing: they approved TEXT,
so that text is what gets stored, verbatim.
* "LET'S TALK ABOUT IT" — the wording, the scope, whether it
wants to be a rule at all. Most good rules arrive this way, so treat
this answer as the expected one rather than a setback.
* "NO" — let it go. If the observation is still worth keeping, it is a
note (create_note): recorded, findable, and binding on nobody.
Where the interface offers structured choices, ask it that way — a
question with named options is answered in a click, while the same
question inside a paragraph is answered by scrolling past. Where it does
not, write the three options out as three options. Either way ask once
and let the answer stand; re-raising a declined proposal argues a rule
into existence, which is the thing this whole loop exists to prevent.
A rulebook rule is shared by every project that gets the rulebook: an
A subscribed rulebook binds the
always_on rulebook binds ALL your projects; a subscribed rulebook binds the
projects that opt in. So a rulebook rule must read as a general standard —
never pin it to one project's files, paths, or quirks. For a rule that
applies to a single project only, use create_project_rule instead (no
@@ -346,7 +327,7 @@ async def create_rule(
instruction. State the moment or the material: "before any git
push", "when adding a value to a CHECK-gated column", "when a
release is being cut". Write it even though the parameter is
optional: it is how the rule is found
optional: it decides the tier below, it is how the rule is found
when it matters, and a rule nobody can place is a rule nobody
applies.
This field is also the rule's RETRIEVAL SURFACE — it and the
@@ -358,14 +339,12 @@ async def create_rule(
brought it back as the top hit. Where a rule prevents a specific
failure, put that failure's vocabulary here — the error text,
the wrong behaviour, the dead end.
The two spellings, side by side:
RETRIEVES: "the migration failed with a check violation on a
column we just extended"
COLLAPSES: "when working on migrations"
The second names a CATEGORY. No session ever produces a
category — it produces the command, the error, the half-formed
ask — so a trigger written that way leaves the embedded
document to be carried by the title alone.
tier: "always_on" (default) or "conditional".
The test: can you name the trigger WITHOUT naming a system, an
artifact type or a moment? If the honest answer is "whenever you
are working", it is always_on. If you had to name something, it is
conditional — and conditional costs nothing when it is irrelevant,
which is what lets it be as long as it needs to be.
system_ids: Ids from list_canonical_systems — the global AREAS this
rule is about. This is what lets a rule reach a project that is
working in that area, so a CI rule surfaces on a CI change.
@@ -404,7 +383,7 @@ async def create_rule(
rule = await rulebooks_svc.create_rule(
topic_id=topic_id, user_id=uid,
title=title, statement=statement, when_to_apply=when_to_apply,
arose_from_id=arose_from_id,
tier=tier, arose_from_id=arose_from_id,
why=why, how_to_apply=how_to_apply, order_index=order_index,
verify_with=verify_with, expires_when=expires_when,
)
@@ -414,8 +393,9 @@ async def create_rule(
async def create_project_rule(
project_id: int, statement: str, title: str = "", when_to_apply: str = "",
why: str = "", how_to_apply: str = "", order_index: int = 0,
tier: str = "always_on", system_ids: list[int] | None = None,
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
system_ids: list[int] | None = None, force: bool = False,
force: bool = False,
) -> dict:
"""Create a rule scoped to a single project (no rulebook needed).
@@ -427,16 +407,6 @@ async def create_project_rule(
the rule is returned in get_project's applicable_rules (under
project_rules) and in list_rules(project_id=...).
PROPOSE, THEN WRITE ON A YES — create_rule's opening carries the whole
loop: the four things a proposal states (what it would require, its
intent, why now, and how it would be enforced) and the one-word question
that closes it (approve as written / talk about it / no). All of it
applies here unchanged. Reach for that loop MORE readily on this surface,
not less: a project rule stays out of an unfiltered list_rules(), and a
conditional one stays out of session start too, so the operator's yes is
the one moment this rule is certain to have been seen by the person it
binds.
Check first whether a rule is the right shape at all — create_rule's
opening asks that question and it applies identically here. A visual
standard is a design system; a procedure is a process (create_process);
@@ -457,12 +427,25 @@ async def create_project_rule(
characters of statement.
when_to_apply: WHEN this rule fires — the trigger, not the
instruction, and the rule's retrieval surface: name the SYMPTOM,
the words someone would type while stuck. Show the moment rather
than classifying it:
RETRIEVES: "the CI job passed locally and fails on the runner
with a permission error"
COLLAPSES: "when touching CI config"
See create_rule for the full argument.
the words someone would type while stuck. See create_rule for the
full argument. It informs the tier below rather than deciding it,
since a project rule's tier turns on area-scope, not on whether
the trigger can be named.
tier: "always_on" (default) or "conditional". The SAME two values as
create_rule, judged against a different cost — do not import that
tool's test wholesale. There, always_on means every session in
every project, so the bar is high: the trigger must be nameless
("whenever you are working"). Here the rule is already scoped to
one project by construction, so always_on costs only that
project's sessions and the bar is correspondingly lower. A
project rule that names something specific is still ordinarily
always_on — being specific is what project rules are FOR.
Reach for conditional when the rule is about one AREA of a large
project — a CI quirk, a migration gotcha, one subsystem's
convention — so it arrives with that area instead of resident in
every session. The failure to avoid is local: forty always-on
rules on one project reproduces, inside that project, exactly the
preload bloat that made every rule compete for the same budget.
system_ids: Ids from list_canonical_systems — the global AREAS this
rule is about. Worth setting even on a project rule: it is what
lets a conditional one surface when the project is working in
@@ -496,7 +479,7 @@ async def create_project_rule(
rule = await rulebooks_svc.create_project_rule(
project_id=project_id, user_id=uid,
title=derived_title, statement=statement, when_to_apply=when_to_apply,
arose_from_id=arose_from_id,
tier=tier, arose_from_id=arose_from_id,
why=why, how_to_apply=how_to_apply, order_index=order_index,
verify_with=verify_with, expires_when=expires_when,
)
@@ -506,48 +489,15 @@ async def create_project_rule(
async def update_rule(
rule_id: int, title: str = "", statement: str = "", when_to_apply: str = "",
why: str = "", how_to_apply: str = "", order_index: int = -1,
system_ids: list[int] | None = None, arose_from_id: int = 0,
verify_with: str = "", expires_when: str = "", kind: str = "",
tier: str = "", system_ids: list[int] | None = None, arose_from_id: int = 0,
verify_with: str = "", expires_when: str = "",
clear_fields: list[str] | None = None,
) -> dict:
"""Update a rule. Empty strings / order_index=-1 leave fields unchanged.
`kind` here is how a rule BECOMES a preference, and it is a real change of
force rather than a relabelling — so make it deliberately and say so. The
rule keeps its id, its history and its typed edges, which is why this is a
field rather than a new record: everything that cites it by number stays
correct. Ordinary edits to an existing preference belong in
update_preference, which asks for what taught the change.
`when_to_apply` IS HOW A RULE ARRIVES AT ALL. Nothing is preloaded since
milestone 394, so a rule with no trigger is not a quiet rule — it is one
no session will ever be shown. `system_ids` REPLACES the rule's areas
(pass [] to clear), and they decide which PROJECTS a rule binds by area.
RETROFITTING A TRIGGER HAS ITS OWN TRAP, and it is not the one create_rule
warns about. There the field is empty and the instruction is "write one".
Here a trigger usually already EXISTS and reads perfectly well as English —
"during hard debugging", "when reading any request from the operator",
"before starting an action while a previous one is still settling" — so the
honest-looking verdict is that it is fine. It is not. Those three named a
CATEGORY rather than a moment, and a category is not a thing any session
ever types. `rule_document()` puts this field in twice, as the title's
other half and again above the body, so it dominates the vector: a trigger
describing the abstraction collapses the record toward its title and the
rule never arrives. Measured in #3835 across 113 rules, and again in #3855
on six preferences written before this was understood.
So when you touch a rule with an old trigger, re-read it against the query
that would have to match it — the command about to run, the code being
written, the operator's actual message — and rewrite it in that vocabulary
if it does not. Prefer the words someone produces while the rule applies,
including the rationalisation they would be drafting to talk themselves out
of it — that rationalisation is often the only text in existence at the
moment the rule should fire:
RETRIEVES: "catching yourself drafting 'this is small enough to not
count' about a rule you have already read"
COLLAPSES: "when the next action would conflict with a standing rule"
See create_rule for the full argument and the measurement behind it.
Adding `when_to_apply` and a `tier` to an existing rule is the ordinary way
a rule stops being preloaded into every session and starts arriving when it
is relevant. `system_ids` REPLACES the rule's areas (pass [] to clear).
TO EMPTY A FIELD, NAME IT: clear_fields=["verify_with"]. Passing "" cannot
do it — "" means "leave this alone" here, which is what lets you update
@@ -576,8 +526,8 @@ async def update_rule(
fields["statement"] = statement
if when_to_apply:
fields["when_to_apply"] = when_to_apply
if kind:
fields["kind"] = kind
if tier:
fields["tier"] = tier
if arose_from_id:
fields["arose_from_id"] = arose_from_id
if why:
@@ -598,271 +548,6 @@ async def update_rule(
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
# ── Preferences ─────────────────────────────────────────────────────────
#
# Separate tools rather than a `kind=` argument on create_rule, and the reason
# is the docstring rather than the data. create_rule's docstring IS the
# approval gate: it tells its caller to propose, offer three answers, and
# wait. A preference reached through that door would be read through that
# prose, and the caller would hesitate over exactly the act this kind exists
# to make routine. Two doors, two contracts, one table.
#
# Reads stay shared — get_rule and list_rules return preferences as they are,
# because a preference IS a rule row and a reader asking "what governs this"
# wants both. Only the WRITE contracts differ.
async def create_preference(
topic_id: int, title: str, statement: str, when_to_apply: str,
arose_from_id: int, why: str = "", how_to_apply: str = "",
order_index: int = 0, force: bool = False,
) -> dict:
"""Record how the operator wants work done. No approval loop — write it.
A PREFERENCE IS NOT A RULE, and the axis is force rather than importance:
* a RULE is what must be FOLLOWED — ignoring it breaks something or
crosses a boundary. It is the operator's decision, so create_rule
proposes and waits for them.
* a PREFERENCE is how they want it DONE — ignoring it costs consistency,
not correctness. Noticing one and recording it is ordinary work.
If the answer to "what happens if someone doesn't do this" is "something
breaks", you are holding a rule: propose it with create_rule instead.
WHY IT IS WORTH RECORDING AT ALL. A preference stated in one session dies
with that session, and the next one re-derives it or asks again. The point
is consistency: the tenth time you do something it goes the way the ninth
did, without the operator having to say so a tenth time.
`when_to_apply` IS REQUIRED, and not as ceremony. A rule's trigger is
two-thirds of its embedded document, so a preference without one is a
record that will never surface at the moment it applies — written,
findable by nobody, and silently useless. Name the moment in the words a
session would actually be producing then: the command it is about to run,
the code it is writing, the thing the operator just asked for.
Show the moment rather than classifying it:
RETRIEVES: "the operator pasted a stack trace and said it is still
broken"
COLLAPSES: "during hard debugging"
The second is a category, and no session ever produces a category — it
produces the command, the error text, the half-formed ask. A trigger
naming the abstraction collapses the record toward its title and it
never arrives. Both of those describe the same preference; only one of
them can be found at the moment it applies.
`arose_from_id` IS REQUIRED for the same kind of reason. A preference is
expected to change as the work teaches it, and a corpus that drifts with
no record of what taught each change is one nobody can audit. Point it at
the task or note where this became clear.
WHAT A PREFERENCE NEVER DOES: change what gets RECORDED. It shapes how
work is done — pacing, phrasing, which tool to reach for, how much to
check first. A dev-log, an issue and a snippet read the same whoever
produced them, because the record has to outlive the person and their
preferences.
A near-duplicate BLOCKS and returns the existing id. That is the whole
reason this corpus can stay small while being written freely: the second
preference about a thing UPDATES the first rather than sitting beside it,
and two preferences that quietly disagree are worse than none — retrieval
surfaces whichever scores higher and nobody learns the other exists. The
gate is title-based within the topic and does not care about kind, so it
also catches a preference restating a rule that already binds.
Args:
topic_id: The rulebook topic to file it under. A preference is
user-scoped: it follows the operator across every project, which
is what separates it from a project rule.
title: What the preference is about. Half the embedded document —
worth as much care as the statement.
statement: How the operator wants it done, in their terms.
when_to_apply: The moment it applies. Required; see above.
arose_from_id: The task or note that taught this. Required; see above.
force: Bypass the near-duplicate gate. For a genuinely distinct
preference, not for one that is "mostly" different — a mostly
different preference is an update.
"""
uid = current_user_id()
if not when_to_apply.strip():
raise ValueError(
"when_to_apply is required: a preference with no trigger never "
"surfaces at the moment it applies. Name that moment in the words "
"a session would be producing then."
)
if not arose_from_id:
raise ValueError(
"arose_from_id is required: preferences change as the work teaches "
"them, and a change with no record of what taught it cannot be "
"audited. Pass the task or note where this became clear."
)
if not force:
dup = await dedup_svc.find_duplicate_rule(title, topic_id=topic_id)
if dup is not None:
return dedup_svc.duplicate_response(dup, "rule")
rule = await rulebooks_svc.create_rule(
topic_id=topic_id, user_id=uid,
title=title, statement=statement, when_to_apply=when_to_apply,
kind="preference", arose_from_id=arose_from_id,
why=why, how_to_apply=how_to_apply, order_index=order_index,
)
return await rulebooks_svc.rule_detail(uid, rule, None)
async def update_preference(
rule_id: int, arose_from_id: int, statement: str = "",
when_to_apply: str = "", title: str = "", why: str = "",
how_to_apply: str = "", order_index: int = -1,
system_ids: list[int] | None = None, clear_fields: list[str] | None = None,
) -> dict:
"""Bring a preference up to date. Doing this mid-work is expected.
THIS IS THE TOOL THAT MAKES A PREFERENCE DIFFERENT FROM A RULE. A rule
waits for its author; a preference is kept current by whoever is working.
When the operator corrects you, or you notice the preference on file no
longer matches how they actually want this done, edit it — that is the
feature, not a liberty being taken. A preference nothing ever updates has
become a rule nobody enforces.
So: no proposal, no three answers, no waiting. Update it and say in the
conversation that you did, so the operator can disagree while it is still
in front of them.
`arose_from_id` IS REQUIRED, and it is the price of the ungated write.
Every edit here is versioned, and the operator can read what changed and
put it back — but a diff with no reason attached leaves them deciding
whether to trust a change they cannot account for. Point at the task or
note that taught it.
WHEN NOT TO EDIT. If what you learned is that something MUST be done a
certain way — that skipping it breaks something or crosses a boundary —
that is a rule, and rules are the operator's call: propose it with
create_rule rather than hardening a preference in place. Softening in the
other direction is equally an edit worth flagging out loud.
EDITING `when_to_apply` IS THE HIGHEST-LEVERAGE EDIT HERE, and the easiest
to skip, because a preference's trigger is load-bearing in a way a rule's
is not. Preferences get a RESERVED slot at the prompt boundary, filled by a
kind-filtered query at limit=1 — so the corpus does not merely rank against
rules, it ranks against ITSELF, and the trigger is almost all of what
separates one preference from the next. Six preferences whose triggers all
named a category ("during hard debugging", "when reading any request from
the operator") made that slot pick close to arbitrarily on every prompt.
So whenever you are here for any reason, read the trigger against the
operator's message that should have summoned it. If it describes a
situation rather than quoting the moment, rewrite it in the words they
actually type — and in the words YOU would be producing while about to get
this wrong:
RETRIEVES: "the operator said 'clean this up' or 'make it work like',
naming an outcome rather than a change"
COLLAPSES: "when reading any request from the operator"
update_rule carries the full argument.
Empty strings leave fields unchanged; clear_fields empties them by name,
exactly as update_rule does.
Args:
rule_id: The preference to update.
arose_from_id: What taught this change. Required; see above.
when_to_apply: The moment it applies, in session vocabulary. See above.
"""
uid = current_user_id()
if not arose_from_id:
raise ValueError(
"arose_from_id is required: this edit is the record of how the "
"operator's preference changed, and a change with no reason "
"attached cannot be judged. Pass the task or note that taught it."
)
fields: dict = {"arose_from_id": arose_from_id}
if title:
fields["title"] = title
if statement:
fields["statement"] = statement
if when_to_apply:
fields["when_to_apply"] = when_to_apply
if why:
fields["why"] = why
if how_to_apply:
fields["how_to_apply"] = how_to_apply
if order_index >= 0:
fields["order_index"] = order_index
rule = await rulebooks_svc.update_rule(
rule_id, uid, clear=clear_fields or (), **fields,
)
if rule is None:
raise ValueError(f"rule {rule_id} not found")
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
async def rule_history(rule_id: int, version_id: int = 0) -> dict:
"""What a rule USED TO SAY, newest change first.
Read this before you argue with a rule, and before you rewrite one. A
rule that has been reworded may have been reworded for a reason you are
about to rediscover the hard way — and the wording it replaced is often
the fastest way to see what the current one is guarding against. The
rescoping of rule 79 is the case this exists for: the superseded
statement had to be hand-copied into a task log to survive the edit.
EACH ENTRY HOLDS THE TEXT THE EDIT REPLACED, not the text it introduced.
So "what did this say before the most recent change?" is the first entry,
and the text the change PRODUCED is the rule as it stands now — read that
with get_rule. Pair the two and you have the diff.
An empty history is ordinary and means the rule has never been reworded,
not that its history was lost. Nothing is written before milestone 323,
so a rule edited before then starts empty too.
Args:
rule_id: The rule whose history to read.
version_id: 0 (default) lists the history — when each change
happened, by whom, and the title as it then stood. Pass an id
from that list to read that snapshot IN FULL. The list omits
statement and why on purpose: a rule's statement runs to
thousands of characters, and a history carrying every field would
cost more to read than the answer is worth.
There is deliberately no restore. Putting an old wording back is a
decision, so it goes through update_rule — which snapshots what it
replaces, leaving the undo visible in the history like any other edit. A
one-click revert would erase the only record of why the rewrite happened.
"""
uid = current_user_id()
if version_id:
version = await rulebooks_svc.get_rule_version(rule_id, version_id, uid)
if version is None:
raise ValueError(
f"version {version_id} not found on rule {rule_id}"
)
return version.to_dict(include_text=True)
versions = await rulebooks_svc.list_rule_versions(rule_id, uid)
if versions is None:
raise ValueError(f"rule {rule_id} not found")
# Fail-open, like the deletes: a missing title must not turn a readable
# history into an error.
try:
rule = await rulebooks_svc.get_rule(rule_id, uid)
except Exception:
rule = None
return {
"rule_id": rule_id,
"title": rule.title if rule else "",
"versions": [v.to_dict(include_text=False) for v in versions],
"total": len(versions),
# Said in-band because an empty list is the ordinary case and reads
# like a missing feature otherwise.
"note": (
"Each entry holds the text the edit REPLACED. The current wording "
"is on the rule itself — get_rule(%d)." % rule_id
if versions else
"This rule has never been reworded."
),
}
async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
"""Move a rule to the trash (recoverable). Requires confirmed=True."""
uid = current_user_id()
@@ -878,9 +563,8 @@ async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
"confirmed_required": True,
}
batch = await trash_svc.delete(uid, "rule", rule_id)
return {"deleted": rule_id, "title": rule.title, "deleted_batch_id": batch,
"message": f'Rule {rule_id} ("{rule.title}") moved to trash. '
f"Restore with restore('{batch}')."}
return {"deleted": rule_id, "deleted_batch_id": batch,
"message": f"Moved to trash. Restore with restore('{batch}')."}
# ── Subscriptions ──────────────────────────────────────────────────────
@@ -890,7 +574,7 @@ async def subscribe_project_to_rulebook(
) -> dict:
"""Subscribe a project to a rulebook — its rules then bind that project.
Subscription is the ONLY path for a rulebook (milestone 394): a reusable,
Subscription is the opt-in path for a non-always_on rulebook: a reusable,
themed module of GENERAL rules shared across the projects that subscribe.
Subscribe a project because it fits the rulebook's theme (e.g. a visual app
-> the design-system rulebook), not to host rules about this one project —
@@ -916,6 +600,34 @@ async def unsubscribe_project_from_rulebook(
# ── Suppressions — project-level mute of rulebook rules / topics ────────
async def exclude_always_on_rulebook(project_id: int, rulebook_id: int) -> dict:
"""Opt a project OUT of a whole always-on rulebook (milestone 297).
Always-on rulebooks bind every project implicitly; an inception decision
can say "not this one, not here". The exclusion is total for that project
— list_always_on_rules(project_id), enter_project/get_project rules and
the session-start context all leave it out and name it under
`excluded_always_on`. Owner-only; the rulebook must be always_on (a
subscribed rulebook is left with unsubscribe_project_from_rulebook).
Idempotent; include_always_on_rulebook reverses it. Normally reached via
decide_project_inception, not by hand.
"""
uid = current_user_id()
await rulebooks_svc.exclude_always_on_rulebook_for_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
)
return {"project_id": project_id, "rulebook_id": rulebook_id, "excluded": True}
async def include_always_on_rulebook(project_id: int, rulebook_id: int) -> dict:
"""Reverse exclude_always_on_rulebook: the always-on rulebook binds this
project again. Idempotent."""
uid = current_user_id()
await rulebooks_svc.include_always_on_rulebook_for_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
)
return {"project_id": project_id, "rulebook_id": rulebook_id, "excluded": False}
async def suppress_rule_for_project(
project_id: int, rule_id: int,
@@ -971,6 +683,8 @@ async def unsuppress_topic_for_project(
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
async def relate_rules(
from_rule_id: int, to_rule_id: int, kind: str, note: str = "",
) -> dict:
@@ -1018,7 +732,7 @@ async def unrelate_rules(relation_id: int) -> dict:
# ── The staleness sweep (milestone 312) ────────────────────────────────
async def rules_due_for_verification(
older_than_days: int = 0, never_only: bool = False,
older_than_days: int = 0, tier: str = "", never_only: bool = False,
) -> dict:
"""Which standing rules assert a FACT that nobody has confirmed lately.
@@ -1045,6 +759,9 @@ async def rules_due_for_verification(
Args:
older_than_days: only rules last verified longer ago than this.
Never-checked rules always qualify. 0 = no age filter.
tier: "always_on" or "conditional" to narrow. An always-on constraint
that has gone false is the expensive kind — it is preloaded into
every session, so a wrong one is wrong everywhere at once.
never_only: only rules nobody has ever verified.
NOT filterable by project, deliberately: a project reaches rules through
@@ -1054,7 +771,7 @@ async def rules_due_for_verification(
"""
uid = current_user_id()
rules = await rulebooks_svc.rules_due_for_verification(
uid, older_than_days=older_than_days, never_only=never_only,
uid, older_than_days=older_than_days, tier=tier, never_only=never_only,
)
return {
"rules": [rulebooks_svc.verification_row(r) for r in rules],
@@ -1107,14 +824,13 @@ def register(mcp) -> None:
for fn in (
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
list_topics, create_topic, update_topic, delete_topic,
list_rules, get_rule,
list_rules, list_always_on_rules, get_rule,
create_rule, create_project_rule, update_rule, delete_rule,
create_preference, update_preference,
relate_rules, unrelate_rules,
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
suppress_rule_for_project, unsuppress_rule_for_project,
suppress_topic_for_project, unsuppress_topic_for_project,
exclude_always_on_rulebook, include_always_on_rulebook,
rules_due_for_verification, mark_rule_verified,
rule_history,
):
mcp.tool(name=fn.__name__)(fn)
+10 -166
View File
@@ -40,6 +40,7 @@ async def _search_rules(uid: int, q: str, limit: int) -> dict:
"title": rule.title,
"statement": rule.statement,
"when_to_apply": rule.when_to_apply or "",
"tier": rule.tier,
"why": rule.why or "",
"how_to_apply": rule.how_to_apply or "",
"verify_with": rule.verify_with or "",
@@ -111,7 +112,6 @@ async def search(
return await _search_rules(uid, q, limit)
is_task = {"note": False, "task": True}.get(content_type) # None => any
t0 = time.perf_counter()
report: dict = {}
raw = await semantic_search_notes(
uid, q, limit=limit, is_task=is_task,
project_id=project_id or None,
@@ -119,16 +119,12 @@ async def search(
# An explicit search reaches everything the operator may read, including
# records shared with them one-to-one.
scope="read",
report=report,
)
record_retrieval(
user_id=uid, source="mcp_search", query=q,
threshold=DEFAULT_SIMILARITY_THRESHOLD, limit=limit,
project_id=project_id or None, is_task=is_task, results=raw,
duration_ms=(time.perf_counter() - t0) * 1000.0,
best_available=report.get("best_available_score"),
best_available_id=report.get("best_available_id"),
searched=bool(report.get("searched", True)),
)
owners = await owner_names_for(
{int(note.user_id) for _s, note in raw if note.user_id != uid}
@@ -153,9 +149,7 @@ async def search(
}
async def retrieval_telemetry(
days: int = 30, near_miss_samples: int = 0,
) -> dict:
async def retrieval_telemetry(days: int = 30) -> dict:
"""What the retrieval telemetry says about YOUR surfaces, over a window.
The read half of the loop the ranker's thresholds are meant to be tuned
@@ -164,82 +158,17 @@ async def retrieval_telemetry(
hand-probing the live instance, which is how the last such decision had to
be made.
Three readouts, from the three tables built for them:
Two readouts, from the two tables built for them:
`sources` — per retrieval surface (`auto_inject`, `write_path`,
`mcp_search`, …), from `retrieval_logs`: `calls`, `zero_result_calls`,
`near_misses`, the `top_score` spread (p10/p50/p90/min/max),
`avg_result_count` and `p90_duration_ms`.
`cleared_threshold` (how often the best hit beat the threshold in force for
that call), the `top_score` spread (p10/p50/p90/min/max), `avg_result_count`
and `p90_duration_ms`. THE number to read first is `cleared_threshold`
against `calls`, with the spread beside it: a surface that clears its bar
on nearly every call is either well-tuned or too loose, and p10 says which.
THE NUMBER TO READ FIRST IS `near_misses.p90`, AGAINST THE THRESHOLD IN
FORCE FOR THAT SURFACE. It is measured on the calls the BAR turned away —
zero-result calls, minus the ones whose zero was a repeat the reader had
already been shown — using the best score the ranker reached before the bar
rejected it. So it is the one figure here that says something the bar
cannot make true by construction, and `max` is always below the threshold:
an above-bar candidate nobody excluded would have been returned. A bar at 0.72 turning away a stream of 0.71s is set too
high by a hair and the surface is losing hits it should have had. The same
bar turning away 0.30s is working, and the corpus simply had nothing. Both
render as a zero-result call, and nothing else in this readout tells them
apart.
`near_miss_samples` (0-20, default 0) TURNS THE PERCENTILES INTO RECORDS
YOU CAN READ. Each source then carries `near_miss_records`: its highest
scoring declines, each with the `record_id` the bar refused and the `query`
that asked. Reach for it whenever you are about to move a threshold.
THE PERCENTILE CANNOT SETTLE A BAR ON ITS OWN, and this is the whole reason
the parameter exists. `near_misses.p90` says mass is sitting just under the
line; it says nothing about whether that mass is RELEVANT, and those are
different questions. Lowering a bar to where the mass is, without reading
what is there, is choosing a firing rate rather than a quality. Pull-through
cannot referee it either — the injected rule line already carries title and
trigger, so a session can comply without ever calling `get_rule`, which
makes rule pull-through understate usefulness by construction. Reading the
rejected records is the method that actually answers it.
Off by default because it is a LISTING, not a statistic: it is for the
moment you are making a decision, not for every readout.
`near_misses` is `null` when no declining call in the window measured it —
rows written before #3670 shipped cannot know. That is "not measured", not
"nothing came close"; a 0.0 there would be a claim about the corpus
invented out of a caller's silence.
A NULL HERE NOW MEANS ONE THING, which it did not at first. A semantic
search returns nothing three ways WITHOUT having run — an empty query, an
unavailable embedder, and a failed database query — and each used to write
a row indistinguishable from a ranker that declined (#3765). Those calls no
longer write a row at all, on the same reasoning that already keeps a blank
command out of the log: a row there reports a call that never happened and
drags the clear rate down with phantom declines. So a null is "searched,
and nothing came close", and a broken search shows up as a WARNING in the
application log rather than as a quiet zero in here.
THERE IS NO `cleared_threshold` ANY MORE, and if you remember one, that
memory is of a tautology (#3670). The search applies the bar before
returning, so every returned result cleared it by construction and a call
with no results has no score to compare: the field was true exactly when
`result_count > 0`, i.e. it was `calls - zero_result_calls` under a name
that promised a second opinion. `zero_result_calls + cleared_threshold ==
calls` held on all nineteen readings ever taken. The reading procedure
built on it — "clears its bar on nearly every call" — asked you to compare
a number with itself.
CHECK `suppression` BEFORE CONCLUDING ANYTHING FROM `zero_result_calls`. A
zero-result call is two different events wearing one number: the ranker
found nothing above the bar, or it found only what this session had already
been shown. Just the first is evidence about the bar. `suppression` splits
them where the surface can tell — `zero_because_already_shown` comes off
`zero_result_calls` to leave the true ranker declines.
`suppression` is `null` when NO row in the window reported it, and that is
"not measured here", NOT "none suppressed". Surfaces that pass their
exclusions into the search never see what was dropped, so they cannot say.
Do not read a null as a zero: reading an artifact as a measurement is how
this surface got mis-scoped once already (#3311, #3497).
`usage` — NOTES ONLY, from `note_usage_events`, at the per-note grain
`usage` — from `note_usage_events`, at the per-note grain
`retrieval_logs` cannot be indexed at: `surfaced` (ranked surfacings — a
scored surface CHOSE the record), `ambient` (the rest), `pulled` split into
`pulled_by_agent` / `pulled_by_human`, the distinct-note counts, and
@@ -253,84 +182,6 @@ async def retrieval_telemetry(
tuned against — only by a pull the agent made. Aggregating across the
mcp_/rest_ prefix would silently answer the wrong one.
`usage["by_source"]` — THE number to tune a threshold against, because the
top-level `pull_through` is a corpus average and averages the surfaces
together. Per surface: `notes_surfaced`, `notes_pulled`, `pull_through`,
and `ambient: true` on surfaces whose surfacings were not scored choices
(their ratio is null — "surfaced often, opened never" is not a judgment
about a record nothing chose). Read it as: of the distinct notes THIS
surface put in front of the agent, how many did the agent then open?
It is an UPPER BOUND per surface: a pull records the door it came
through, not the surface that led there, so a note surfaced by two surfaces
and opened once counts for both — attribution would need the session
identity #2085 declined to invent. `by_source_failed: true` means that one
query failed while the rest of the readout stood.
`rule_usage` — the same question for RULES, from `rule_usage_events`:
`surfaced` and `ambient`, `pulled` split into `pulled_by_agent` /
`pulled_by_human`, the distinct-rule counts, and `pull_through` on the same
definition (agent pulls over RANKED surfacings).
A SEPARATE BLOCK, not folded into `usage`, and reading it as one number
with that is the mistake to avoid. The corpora differ by orders of
magnitude — a few dozen eligible rules against thousands of notes — so a
blended ratio would be the note ratio with noise on it and would hide the
rule arm entirely.
`surfaced` VS `ambient` IS THE READING THAT MATTERS HERE. `surfaced` counts
rules a ranker chose — today only the write-path arm — and those are claims
a pull can settle. `ambient` counts BULK DELIVERIES: the SessionStart
preload and the `rules_payload` surfaces
(`enter_project`, `get_project`, `get_milestone`, `start_planning`,
`get_task`), which hand over the whole applicable set at once with nobody
choosing anything. A large `ambient` says the resident set is big and
arrives often — never that it is useful, and never that it is read.
`pull_through` therefore divides by `surfaced` alone. Fold the preload in
and growing the always-on set would depress the arm's measured precision
while trimming it would flatter it, for reasons having nothing to do with
the arm. To judge the PRELOAD instead, compare `ambient` against pulls of
those same rules over time: a resident set surfaced thousands of times and
opened never is the dead-weight signal, one tier up.
Read it against `sources["write_path_rule"]`. That arm was once believed
never to decline — the reading that scoped #3311 — but it was the arm's
`retrieval_logs` row being written only on calls that FOUND something, so
the zeros were missing rather than absent (#3497). Measured since, it
declines the large majority of its calls like any other surface.
EVERY COUNTER BLOCK CARRIES ITS OWN COVERAGE — `complete_from` and
`covers_window`. `complete_from` is when the number became trustworthy:
for one source, its first recorded row; for a section that sums several,
the LATEST of theirs, because a total is complete only once every
contributor was being written. `covers_window: false` means the window
reaches back further than the recording does, so the count is a fraction
of the period it appears to describe.
READ IT BEFORE COMPARING TWO NUMBERS, and especially before comparing
across a deploy. A counter added last week, read over a 30-day window,
reports a real count against an imagined denominator — and the result is
a plausible fraction rather than an obvious zero, which is what makes it
dangerous. That reading cost milestone #379 five steps aimed at a defect
that did not exist.
`covers_window` is null, never false, when nothing was ever recorded:
"no measurement" is not "partial measurement", the same distinction
`suppression`'s null carries a few paragraphs up.
A SOURCE SHOWING `calls: 0` WAS RECORDING AND MADE NO CALLS. `sources`
lists every source the table has ever held, not only those active in the
window, so a surface that stopped firing stays visible rather than
disappearing — being absent is reserved for a source that has never
recorded at all. Its score fields are null, not zero: the calls are a
real observation, the distribution is not one.
`rule_usage_failed: true` means that read failed while the rest of the
readout stood. The counts are still present so a caller can render, but
they are zeros meaning "could not find out", not "nothing happened" — do
not report a pull-through from a block carrying that flag.
Scoped to your own telemetry — a retrieval log records what your agent
asked for, query text included, and is not a shared record kind.
@@ -340,15 +191,8 @@ It is an UPPER BOUND per surface: a pull records the door it came
Args:
days: window size, default 30. Clamped to at least 1.
near_miss_samples: 0-20, default 0. How many of each source's highest
scoring DECLINES to list by record, with the query that asked.
Pass it when you are about to move a threshold; leave it off
otherwise. See the near-miss section above for why a percentile
alone cannot settle a bar.
"""
return await retrieval_summary(
current_user_id(), days=days, near_miss_samples=near_miss_samples,
)
return await retrieval_summary(current_user_id(), days=days)
def register(mcp) -> None:
+1 -11
View File
@@ -494,19 +494,9 @@ async def delete_snippet(snippet_id: int) -> dict:
that should survive, prefer merge_snippets — that keeps the call sites.
"""
uid = current_user_id()
# Read before deleting so the confirmation can NAME what went — an id
# alone leaves the operator unable to tell which snippet this was.
# Fail-open: the title is a COURTESY on top of the delete, so a lookup
# that errors must not stop the delete happening. Same posture the
# staleness marker takes — a decoration may never break its payload.
try:
doomed = await snippets_svc.get_snippet(uid, snippet_id)
title = getattr(doomed, "title", "") if doomed else ""
except Exception:
title = ""
if not await snippets_svc.delete_snippet(uid, snippet_id):
raise ValueError(f"snippet {snippet_id} not found")
return {"deleted": True, "id": snippet_id, "title": title}
return {"deleted": True, "id": snippet_id}
async def merge_snippets(target_id: int, source_ids: list[int]) -> dict:
+3 -15
View File
@@ -103,7 +103,7 @@ async def get_task(task_id: int) -> dict:
applicable = await rulebooks_svc.get_applicable_rules(
project_id=note.project_id, user_id=uid,
)
data.update(rulebooks_svc.rules_payload(applicable, user_id=uid, source="get_task"))
data.update(rulebooks_svc.rules_payload(applicable))
data.update(await access_svc.describe_provenance(uid, note))
# Same reasoning as get_note's record_pulled, and this is the tool where it
# matters MOST: auto-inject ranks kind-blind over a corpus that is
@@ -364,23 +364,11 @@ async def delete_task(task_id: int) -> dict:
"""Move a Scribe task (or plan) to the trash (recoverable). Sub-tasks go with it.
Restore via restore(batch_id)."""
uid = current_user_id()
# Read the title BEFORE the delete: afterwards the row is trashed and the
# confirmation could only echo the number back. A deletion the operator
# cannot recognise is one they cannot tell was the wrong one.
# Fail-open: the title is a COURTESY on top of the delete, so a lookup
# that errors must not stop the delete happening. Same posture the
# staleness marker takes — a decoration may never break its payload.
try:
loaded = await notes_svc.get_note_for_user(uid, task_id)
title = getattr(loaded[0], "title", "") if loaded else ""
except Exception:
title = ""
batch = await trash_svc.delete(uid, "task", task_id)
if batch is None:
raise ValueError(f"task {task_id} not found")
return {"deleted": task_id, "title": title, "deleted_batch_id": batch,
"message": f'Task {task_id} ("{title}") moved to trash. '
f"Restore with restore('{batch}')."}
return {"deleted_batch_id": batch,
"message": f"Task {task_id} moved to trash. Restore with restore('{batch}')."}
def register(mcp) -> None:
-2
View File
@@ -28,13 +28,11 @@ from scribe.models.invitation import InvitationToken # noqa: E402, F401
from scribe.models.embedding import NoteEmbedding, RuleEmbedding # noqa: E402, F401
from scribe.models.retrieval_log import RetrievalLog # noqa: E402, F401
from scribe.models.note_usage import NoteUsageEvent # noqa: E402, F401
from scribe.models.rule_usage import RuleUsageEvent # noqa: E402, F401
from scribe.models.project import Project # noqa: E402, F401
from scribe.models.milestone import Milestone # noqa: E402, F401
from scribe.models.task_log import TaskLog # noqa: E402, F401
from scribe.models.note_draft import NoteDraft # noqa: E402, F401
from scribe.models.note_version import NoteVersion # noqa: E402, F401
from scribe.models.rule_version import RuleVersion # noqa: E402, F401
from scribe.models.note_supersession import NoteSupersession # noqa: E402, F401
from scribe.models.group import Group, GroupMembership # noqa: E402, F401
from scribe.models.share import NoteShare, ProjectShare # noqa: E402, F401
+1 -5
View File
@@ -51,11 +51,7 @@ class RuleEmbedding(Base):
"""One embedding vector per CHUNK of a rule (milestone 307, note 3026).
A SIBLING of NoteEmbedding rather than a generalisation of it, decided
deliberately. The reasoning below turned out to be the only written
statement of a rule Scribe applies to every record type, so it is now also
NOTE 3163 — "When a record type earns its own table" — with the worked
cases and the cost of leaving `notes`. Read that before splitting a record
type off; this stays here because it is where the decision was made.
deliberately:
- The embedding ROW could have been made polymorphic. The SEARCH could not.
`semantic_search_notes` is a long function of Note-specific scoping —
+2 -41
View File
@@ -94,40 +94,9 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
# name/language/signature/locations live here so they can be INDEXED. The
# body keeps the same facts in readable markdown and remains what gets
# embedded; this is a mirror for querying, not the source of truth for
# display — and it is DERIVED, so every path that writes a snippet's body
# rewrites it too (services/snippets.recompose_data, called from
# notes.update_note). 0070 left it NULL on existing rows and
# snippets.backfill_snippet_data filled them at startup; readers still fall
# back to parsing the body when it is absent (snippet_fields).
# display. NULL on every row written before migration 0070, so readers fall
# back to parsing the body (see services/snippets.snippet_fields).
data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# The three fields that tell a CONSTRAINT apart from a NORM (milestone
# 317, migration 0092) — the same trio `rules` carries, and for the same
# reason. A norm is a decision: no truth value, changes only when its
# author changes it. A constraint asserts a fact about someone else's
# software and goes false with nobody watching. Only constraints get a
# check.
#
# `verify_with` is how to check it is still true; `expires_when` is the
# STATE that ends it, deliberately not a date — constraints expire when
# the ground moves, not on a schedule. `verified_at` NULL means never
# checked and sorts FIRST in the sweep: unexamined outranks
# examined-long-ago.
#
# These sit on `notes`, so every kind of row in this table has them, but
# only non-task, non-snippet records are OFFERED them (gated in
# services/notes.py). A task's decay is its status — a done issue records
# what happened and cannot go false — and a snippet already carries a
# richer, location-aware verdict in `data.verification`. On those rows
# these stay null, which is also what they mean.
#
# Most notes should leave all three empty. A null `verify_with` is not a
# gap; it is the marker for "this is a decision, there is nothing to go
# and check", and the sweep is only worth reading while that holds.
verify_with: Mapped[str | None] = mapped_column(Text, nullable=True)
expires_when: Mapped[str | None] = mapped_column(Text, nullable=True)
verified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
__table_args__ = (
Index("ix_notes_tags", "tags", postgresql_using="gin"),
@@ -168,14 +137,6 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
"is_task": self.is_task,
"note_type": self.note_type or "note",
"task_kind": self.task_kind,
# Serialized unconditionally, like every other field a given row
# kind may not use (recurrence, started_at, the task fields). The
# DERIVED "last_verified" label is the one that appears only when
# a check exists — a raw projection of the row should not make a
# client branch on which keys are present.
"verify_with": self.verify_with or "",
"expires_when": self.expires_when or "",
"verified_at": iso(self.verified_at),
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
+1 -1
View File
@@ -39,7 +39,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
)
# The inception record (milestone 297): what this project was decided to
# inherit, when, and through which door — {decided_at, decided_by, via,
# choices: {subscribe_rulebooks,
# choices: {exclude_always_on_rulebooks, subscribe_rulebooks,
# design_system_id, seed_systems}}. NULL means nobody has decided yet,
# and enter_project asks; the effects themselves live in the subscription
# / exclusion tables, design_system_id and the project's Systems — this is
-28
View File
@@ -42,35 +42,8 @@ class RetrievalLog(Base):
# False=notes, NULL=any.
is_task: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
result_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# How many scored hits this call DROPPED because the session had already
# been shown them. NULLABLE, and the null is load-bearing: it means "this
# surface does not report suppression", which must not read as "nothing was
# suppressed". `result_count == 0` alone conflates two different events —
# the ranker found nothing above threshold, and the ranker found something
# the reader already had — and only the first says a threshold is too high.
# Reading a zero as a ranker decline is how #3311 mis-scoped a milestone;
# an unmeasured value that renders as 0 is the same mistake with a nicer
# face, so surfaces that filter INSIDE the search leave this null.
suppressed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
top_score: Mapped[float | None] = mapped_column(Float, nullable=True)
min_score: Mapped[float | None] = mapped_column(Float, nullable=True)
# The best score the ranker COULD have offered, before the threshold — as
# against `top_score`, which is the best it DID offer. They are equal on
# any call that returned something, and only this one exists on a call
# that returned nothing, which is the only place a bar can be judged from
# (#3670). Null means the caller did not measure it, never "nothing was
# close": a 0.0 there would read as a corpus with no relevant records at
# all, which is an artifact standing in for a measurement.
best_available_score: Mapped[float | None] = mapped_column(Float, nullable=True)
# WHICH record scored that, so a reader can judge what the bar refused
# rather than only how close it came (#3807). Written from the same ranked
# candidate as the score above — the two describing different records would
# be worse than no id at all, because it invites judging the wrong one.
#
# Not a foreign key on purpose: this table spans record types (the rule arms
# store rule ids, the note arms store note ids) and `source` is what says
# which, exactly as `result_ids` has always worked.
best_available_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# [{"id": int, "score": float, "rank": int}, ...], highest-first.
result_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
@@ -94,7 +67,6 @@ class RetrievalLog(Base):
"project_id": self.project_id,
"is_task": self.is_task,
"result_count": self.result_count,
"suppressed_count": self.suppressed_count,
"top_score": self.top_score,
"min_score": self.min_score,
"result_ids": self.result_ids,
-95
View File
@@ -1,95 +0,0 @@
from sqlalchemy import BigInteger, Index, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin, iso
SURFACED = "surfaced"
PULLED = "pulled"
class RuleUsageEvent(Base, CreatedAtMixin):
"""One row per time a rule was SURFACED to the agent, or PULLED in full.
The sibling `note_usage_events` has had since 2026-07, third in the line
after `rule_embeddings` and `rule_versions` — and, like those, it exists
because the rule side kept inheriting machinery built for notes and
quietly getting the weaker version of it.
WHY RULES NEED THEIR OWN AND CANNOT SHARE THE NOTE TABLE. Not squeamishness
about a polymorphic column — the row shares no note-specific fields and the
aggregate readout is the same shape, which is the strongest case for
sharing that note #3163 admits. What decides it is IDENTITY AT RESTORE. A
note id and a rule id are different namespaces resolved through different
maps, and `note_usage_events`'s importer maps `note_id` through
`note_id_map` and drops what does not resolve. A rule id parked in that
column would come back from a backup silently reattached to whatever note
happened to take that number — telemetry that is not merely lost but wrong,
and wrong in a way nothing downstream could detect.
WHAT THIS MEASURES, AND WHY IT DID NOT EXIST. The write-path standing-rule
arm is the only retrieval surface in Scribe whose usefulness cannot be
observed — and, not coincidentally, the only one that has never declined to
fire (#3311: 296 calls, zero zero-result, 100% clearing its threshold).
`retrieval_logs` gives it scores; scores say what the ranker thought, never
whether the hint landed. Without a pull counter no install can tune the arm
from evidence, only from the shape of a histogram.
Deliberately FK-FREE on `rule_id` and `user_id`, matching `note_usage_events`,
`retrieval_logs` and `app_logs` — and diverging from `rule_versions`, which
does carry FKs. The difference is what the row is FOR: a version is part of
a rule's history and dies with it, while telemetry outlives the row it
describes. Deleting a rule must not erase the evidence that it was surfaced
forty times and opened never, because that evidence is precisely the case
for having deleted it.
Cells left deliberately empty (note #3163's step 3): no share ACL — rules
have none of their own; no soft delete — nothing restores a telemetry row,
and the table is append-only; no embedding — an event is not a document.
"""
__tablename__ = "rule_usage_events"
# BigInteger throughout, where the note twin uses Integer. `rule_id` has to
# be, since `rules.id` is BigInteger — and once one column is, matching the
# rest costs nothing and keeps the row uniform. A high-churn append-only
# telemetry table is a poor place to discover an id ceiling.
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
rule_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
# 'surfaced' | 'pulled'
event: Mapped[str] = mapped_column(Text, nullable=False)
# Which surface produced it. A CONVENTION, not a fixed vocabulary, and the
# note twin's comment explains why this one deliberately does not enumerate
# its members: the previous such list went stale, naming a source nothing
# wrote while omitting ones that existed, and a half-true enumeration reads
# as authoritative in exactly the way that misleads (#2476).
# `grep -rn record_rule_pulled\|record_rule_surfaced src/` is the
# authoritative list, and unlike a comment it cannot drift.
#
# The mcp_/rest_ prefix split is load-bearing here for the same reason it is
# for notes, and more so: "is this rule dead weight?" is served by any pull,
# but "did that injected hint land?" — the question this arm exists to
# answer — is served by AGENT pulls only. Never aggregate across the prefix
# without saying why.
source: Mapped[str] = mapped_column(Text, nullable=False)
__table_args__ = (
# Every readout is "these rule ids, split by event" — a covering
# composite beats separate single-column indexes for it.
Index("ix_rule_usage_rule_event", "rule_id", "event"),
Index("ix_rule_usage_created_at", "created_at"),
Index("ix_rule_usage_user_id", "user_id"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"created_at": iso(self.created_at),
"user_id": self.user_id,
"rule_id": self.rule_id,
"event": self.event,
"source": self.source,
}
-96
View File
@@ -1,96 +0,0 @@
from datetime import datetime
from sqlalchemy import BigInteger, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
from scribe.models.base import CreatedAtMixin, iso
class RuleVersion(Base, CreatedAtMixin):
"""One snapshot of a rule's text, taken before an edit overwrote it.
THE SIBLING NOTES ALREADY HAD. `note_versions` has existed for a long
time, and the design-system note calls its history "the changelog". Rules
— which BIND BEHAVIOUR on every session that loads them — had nothing, so
an edit destroyed what the rule used to say. Rescoping rule 79 on
2026-08-29 meant hand-copying the superseded statement into a task log to
keep it (#3237). The more consequential record had the weaker protection.
`CreatedAtMixin`, not `TimestampMixin`: a version is an EVENT. It is
written once and never updated, so an `updated_at` on it would be a column
that can only ever lie.
WHAT IS DELIBERATELY DIFFERENT FROM NoteVersion (milestone 323):
- `user_id` is the ACTOR — who made the edit — where NoteVersion's is the
owner, because `update_note` passes an owner-scoped id. For an audit
trail over a binding instruction, "who changed this" is the question
being asked, and a rule is editable by anyone with rulebook access.
- No `pin_kind` / `pin_label`. Those exist so a note's version can survive
autosave pruning. Nothing prunes here, so a pin would protect a row that
was never at risk.
- TEXT ONLY. A rule's Systems and its typed relations are edges with their
own lifecycle; folding them in would make one word, "version", mean two
different things — the rule's wording, and the rule's place in the
graph. `verified_at` is likewise absent: it is a stamp about a check,
not a property of the text, and step 2's snapshot is taken before
update_rule clears it precisely so the history shows the check that was
in force when this wording was written.
"""
__tablename__ = "rule_versions"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
rule_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True
)
# The actor. SET NULL rather than CASCADE: deleting a user must not erase
# the history of the rules they edited — the edit still happened, and the
# rule is still binding because of it. NoteVersion cascades because a
# note's versions belong to its owner; a rule's belong to the rule.
user_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
title: Mapped[str] = mapped_column(Text, default="")
statement: Mapped[str] = mapped_column(Text, default="")
why: Mapped[str | None] = mapped_column(Text, nullable=True)
how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
when_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
# Carried so that a change of FORCE leaves a trace. `record_if_changed`
# snapshots only the fields a version holds, so a kind omitted here would
# make "this stopped binding" the one edit with no history behind it.
# NULL means a version older than migration 0098 — not recorded, never
# "was a rule".
kind: Mapped[str | None] = mapped_column(Text, nullable=True)
verify_with: Mapped[str | None] = mapped_column(Text, nullable=True)
expires_when: Mapped[str | None] = mapped_column(Text, nullable=True)
def to_dict(self, include_text: bool = True) -> dict:
"""The row. `include_text=False` gives the listing form.
A rule's `statement` and `why` run to thousands of characters — rule
149's `why` alone is longer than most notes — so a history LIST that
carried every field would be unreadable and expensive. The listing
answers "when, and by whom"; opening one answers "and what did it
say". Same split NoteVersion makes with `include_body`.
"""
out: dict = {
"id": self.id,
"rule_id": self.rule_id,
"user_id": self.user_id,
"title": self.title,
"created_at": iso(self.created_at),
}
if include_text:
out.update({
"statement": self.statement,
"why": self.why or "",
"how_to_apply": self.how_to_apply or "",
"when_to_apply": self.when_to_apply or "",
"kind": self.kind or "",
"verify_with": self.verify_with or "",
"expires_when": self.expires_when or "",
})
return out
+25 -37
View File
@@ -19,6 +19,9 @@ class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
)
title: Mapped[str] = mapped_column(Text)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
always_on: Mapped[bool] = mapped_column(
Boolean, default=False, nullable=False, server_default="false"
)
def to_dict(self) -> dict:
return {
@@ -26,6 +29,7 @@ class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
"owner_user_id": self.owner_user_id,
"title": self.title,
"description": self.description or "",
"always_on": self.always_on,
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
@@ -92,32 +96,15 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
# WHEN this rule applies — the trigger, not the instruction. Required of
# new rules at the service layer and nullable here, because rules written
# before migration 0088 have none and a migration cannot invent one.
# It carries three jobs at once (note 3026): it is the readable form of
# the canon tag, the half of the document that makes a rule findable by
# meaning, and — since milestone 394 removed the always-on tier — the ONLY
# thing that decides whether a rule ever reaches a session at all. A rule
# with no trigger is not a quiet rule, it is an unreachable one.
# It carries three jobs at once (note 3026): it is the tier test made
# concrete, the readable form of the canon tag, and the half of the
# document that makes a rule findable by meaning.
when_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
# WHAT KIND of instruction this is. `tier` used to sit beside this and
# carry delivery; milestone 394 removed it, so kind is now the only axis
# on a rule and delivery belongs entirely to retrieval.
# `rule` must be FOLLOWED: ignoring it breaks something or crosses a
# boundary. `preference` is how this person wants work DONE: ignoring it
# costs consistency, not correctness.
#
# The second half is what makes it a kind rather than a softer label — a
# preference is expected to CHANGE as the work teaches it, and the agent
# updates it in the ordinary course of working, where a rule waits for its
# author. So one column decides two behaviours: whether create's approval
# gate fires, and which voice the injected line speaks in.
#
# Lives here and not in its own table because a preference needs exactly
# what a rule has and a note does not — a trigger column, a
# trigger-dominated document, ownership-scoped search, the retrieval arms,
# relations, and `rule_versions`, which is where its drift is recorded.
# Defaults to `rule` so nothing changes force on upgrade.
# CHECK ck_rules_kind (migration 0098, rule 36).
kind: Mapped[str] = mapped_column(Text, default="rule", server_default="rule")
# always_on = preloaded into every session, as every rule is today.
# conditional = reachable, and surfaced when its trigger fires. The
# default preserves existing behaviour exactly: nothing stops binding
# because of an upgrade. CHECK ck_rules_tier (migration 0088, rule 36).
tier: Mapped[str] = mapped_column(Text, default="always_on", server_default="always_on")
why: Mapped[str | None] = mapped_column(Text, nullable=True)
how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
# The three fields that tell a CONSTRAINT apart from a NORM (milestone
@@ -156,13 +143,7 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
"title": self.title,
"statement": self.statement,
"when_to_apply": self.when_to_apply or "",
# Unconditional, unlike the `if present` keys below. A reader
# deciding how much force a record carries must never infer it
# from an ABSENT key: "no kind field" and "kind is rule" would be
# the same payload, and that equivalence is the defect shape this
# codebase keeps re-encountering. Twenty bytes buys an answer that
# cannot be misread.
"kind": self.kind or "rule",
"tier": self.tier,
"why": self.why or "",
"how_to_apply": self.how_to_apply or "",
"verify_with": self.verify_with or "",
@@ -256,11 +237,18 @@ project_rule_suppressions = Table(
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
# `project_rulebook_exclusions` lived here until milestone 394. It recorded a
# project's opt-out of a whole always-on rulebook — which only made sense
# while a rulebook could bind a project WITHOUT being asked. Subscription is
# now the only reach a rulebook has, so declining one is expressed by not
# subscribing, and there is nothing left to opt out of.
# A project's opt-out of a whole ALWAYS-ON rulebook (milestone 297): the
# sibling of the two suppression tables below, one level up. Always-on
# rulebooks bind every project implicitly; an inception decision can exclude
# specific ones for this project, and get_applicable_rules /
# list_always_on_rules(project_id) skip them. FKs CASCADE like the others.
project_rulebook_exclusions = Table(
"project_rulebook_exclusions",
Base.metadata,
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
project_topic_suppressions = Table(
"project_topic_suppressions",
+1 -56
View File
@@ -10,61 +10,6 @@ async def health():
return jsonify({"status": "ok"})
def build_version_payload() -> dict:
"""What build is this, separated into the values that answer different
questions (rule 149).
UNTIL 2026-08-31 THIS RETURNED THE CHANNEL. `BUILD_VERSION` in CI was
literally "dev" / "main" / the tag, so a running instance reported
`{"version": "main"}` — a channel name sitting where a build identifier
belongs. The cost was concrete: with a deploy misbehaving, nothing on the
instance could say which commit was serving it, and the one endpoint whose
job that is answered with the name of a branch.
The three values, and why they are three:
- `version` — the NAME, `YYYY.MM.DD.HHMM` from COMMIT time. Answers "is
this the same code?", so two channels carrying one commit report the
same string.
- `build` — the ORDERING KEY, minutes since 2020-01-01 from BUILD time.
Answers "may this be installed over that?". The ONLY value anything may
compare; it is monotonic by construction, which neither a commit count
(branches diverge) nor a commit time (rebuilds go backwards) is.
- `channel` — its own field, never folded into the name.
Plus `commit`, so the artifact's claim about itself can be checked against
the `:<sha>` it was published under (rule 145).
ABSENT RATHER THAN EMPTY when unknown. A local build has no ordering key
and no channel, and saying so is honest; emitting `""` or a placeholder
would let it claim a position in an update order it is not part of. A
reader must treat a missing `build` as "cannot be ordered", not as zero.
"""
payload: dict = {"version": os.environ.get("APP_VERSION", "dev")}
# Reported verbatim, never validated against an enum — a build claiming
# something unexpected is better shown than dropped (rule 149).
for key, env in (("channel", "APP_CHANNEL"), ("commit", "APP_COMMIT")):
value = (os.environ.get(env) or "").strip()
if value:
payload[key] = value
raw_key = (os.environ.get("APP_BUILD_KEY") or "").strip()
if raw_key:
try:
# An INTEGER, not a string. A string ordering key is how a
# comparison silently becomes lexicographic — "9" > "10" — which
# is the same class of fault as folding the channel in: it reads
# fine and orders wrong.
payload["build"] = int(raw_key)
except ValueError:
# A malformed key is omitted rather than passed through: a reader
# that cannot order is correct, one that orders on garbage is not.
pass
return payload
@api.route("/version")
async def version():
return jsonify(build_version_payload())
return jsonify({"version": os.environ.get("APP_VERSION", "dev")})
+4 -12
View File
@@ -1,4 +1,4 @@
"""Unified Knowledge endpoint — every record kind in one queryable feed."""
"""Unified Knowledge endpoint — notes, tasks, plans, and processes in one queryable feed."""
import logging
from quart import Blueprint, jsonify, request
@@ -6,18 +6,12 @@ from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.routes.utils import parse_pagination
from scribe.services.access import label_shared_items
from scribe.services.knowledge import FACET_TYPES
logger = logging.getLogger(__name__)
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
# Derived from the service's facet table, never re-listed here. This set was a
# hand-kept copy and had drifted three kinds behind it: it admitted `plan`
# (retired in 0066) and rejected `issue` (shipped in 0065, 435 rows) and
# `snippet` — so the browse surface could not filter to the kinds it was
# already rendering badges for (#3128).
_VALID_TYPES = FACET_TYPES
_VALID_TYPES = {"note", "task", "plan", "process"}
_VALID_SORTS = {"modified", "created", "alpha", "type"}
@@ -27,9 +21,7 @@ async def list_knowledge():
"""Return paginated knowledge objects with optional filtering.
Query params:
type — a facet from services.knowledge._FACETS: a record type
(note|process|snippet) or a task kind (task for any,
else work|issue|spike|plan). Omit for all.
type — one of note|task|plan|process (omit for all)
tags — comma-separated tag filter (AND logic)
sort — modified|created|alpha|type (default: modified)
q — search query (semantic when provided, keyword fallback)
@@ -135,7 +127,7 @@ async def get_knowledge_batch():
@knowledge_bp.route("/tags", methods=["GET"])
@login_required
async def list_knowledge_tags():
"""Return all tags used across knowledge objects, narrowed to one facet."""
"""Return all tags used across knowledge objects (excludes tasks)."""
uid = get_current_user_id()
note_type = request.args.get("type", "").strip().lower() or None
+1 -72
View File
@@ -19,10 +19,7 @@ from scribe.services.notes import (
get_note_for_user,
get_or_create_note_by_title,
list_notes,
mark_note_verified,
notes_due_for_verification,
update_note,
verification_row,
)
from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft
from scribe.services import dedup as dedup_svc
@@ -115,8 +112,6 @@ async def create_note_route():
priority=priority,
due_date=due_date,
note_type=note_type,
verify_with=data.get("verify_with"),
expires_when=data.get("expires_when"),
)
except ValueError as e:
return jsonify({"error": str(e)}), 400
@@ -253,14 +248,7 @@ async def update_note_route(note_id: int):
owner_uid = note_obj.user_id
data = await request.get_json()
fields = {}
for key in (
"title", "body", "description", "parent_id", "project_id",
"milestone_id", "status", "priority", "note_type",
# A cleared form input arrives as "" and the service reads that as
# NULL (NULLABLE_NOTE_TEXT), so this door expresses "remove the check"
# with its own idiom and needs no `clear` list (milestone 317).
"verify_with", "expires_when",
):
for key in ("title", "body", "description", "parent_id", "project_id", "milestone_id", "status", "priority", "note_type"):
if key in data:
fields[key] = data[key]
if "due_date" in data:
@@ -502,62 +490,3 @@ async def graph_route():
shared_tags = request.args.get("shared_tags", "false").lower() == "true"
graph = await build_note_graph(uid, project_id=project_id, include_shared_tags=shared_tags)
return jsonify(graph)
# ── The staleness sweep (milestone 317) ──────────────────────────────────────
# The web half of notes_due_for_verification / mark_note_verified. Same
# contract as the MCP door and the rules routes beside it — the service holds
# the behaviour, these two just parse and serialise.
@notes_bp.route("/due-for-verification", methods=["GET"])
@login_required
async def notes_due_route():
"""Notes that carry a check, oldest verification first, never-checked top.
Query params: older_than_days, project_id, never_only. A note with no
`verify_with` never appears — it is a decision, not a fact.
"""
uid = get_current_user_id()
args = request.args
try:
older = int(args.get("older_than_days", 0) or 0)
project = int(args.get("project_id", 0) or 0)
except ValueError:
return jsonify({"error": "older_than_days and project_id must be integers"}), 400
try:
notes = await notes_due_for_verification(
uid,
older_than_days=older,
project_id=project or None,
never_only=args.get("never_only", "").lower() in ("1", "true", "yes"),
)
except ValueError as exc:
# A 400, not a silently narrowed result: a filter that quietly answers
# a different question is the failure this whole surface exists to
# catch.
return jsonify({"error": str(exc)}), 400
return jsonify({
"notes": [verification_row(n) for n in notes],
"total": len(notes),
})
@notes_bp.route("/<int:note_id>/verify", methods=["POST"])
@login_required
async def mark_note_verified_route(note_id: int):
"""Record that the note's check was run. Body: {"still_true": bool}.
`still_true: false` writes nothing — a note whose check failed is wrong,
not in a recordable state — so it keeps its place at the top of the sweep.
"""
data = await request.get_json() or {}
uid = get_current_user_id()
still_true = bool(data.get("still_true", True))
note = await mark_note_verified(note_id, uid, still_true)
if note is None:
return jsonify({
"error": "note not found, not writable by you, or carries no verify_with"
}), 404
payload = verification_row(note)
payload["verified"] = still_true
return jsonify(payload)
-64
View File
@@ -91,77 +91,13 @@ async def autoinject_retrieve():
project_id (opt) — explicit project scope override (ad-hoc/testing).
exclude_ids (opt) — comma-separated note ids already injected this
session; skipped so each note injects at most once.
exclude_rule_ids — comma-separated rule ids already surfaced this
(opt) session. SHARED with /prior-art and /tool-rules on
purpose: one session keeps ONE rule ledger, so a
rule named by any arm is not re-announced by
another. Ages out (#3751), so salience decays.
TWO ARMS, TWO SETS OF GATES. Rules ride the same hook and the same query
but nothing else: the notes menu can be disabled, thresholded and top-k'd
by the operator without touching whether a rule reaches them. Composed
here rather than inside either builder so neither one's early return can
silently suppress the other.
Rules come FIRST in the payload. A rule or preference governing the answer
is more consequential than a menu of things that might be worth reading,
and a reader who stops after the first block should have stopped after
the right one.
"""
q = (request.args.get("q") or "").strip()
project_id, _repo, _unbound = await _project_scope()
exclude_ids = _int_list(request.args.get("exclude_ids"))
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
rules = await plugin_ctx_svc.build_prompt_rule_hint(
g.user.id, q, project_id=project_id, exclude_rule_ids=exclude_rule_ids
)
result = await plugin_ctx_svc.build_autoinject_hint(
g.user.id, q, project_id=project_id, exclude_ids=exclude_ids
)
blocks = [b for b in (rules["context"], result["context"]) if b]
result["context"] = "\n\n".join(blocks)
result["rule_ids"] = rules["rule_ids"]
return jsonify(result)
@plugin_bp.get("/tool-rules")
@login_required
async def pre_tool_rules():
"""Standing rules for the plugin's PreToolUse hook on ACTIONS (#3476).
Answers "does a recorded rule speak to the command about to be run?" — the
sibling of /prior-art, which can only answer that question about a code
write. Rules about which tool to reach for (don't curl the forge, don't
stand up a stack, don't run the suite locally) had no retrieval surface at
all before this, which is why they all had to live in the resident preload.
Titles + trigger only, never the statement: the hint says a rule may apply
and hands over `get_rule(id)`. One rule at most (RULEHINT_LIMIT), and empty
most of the time.
Query:
tool (str) — the tool about to run, e.g. `Bash`. Used in
the hint's wording, not in the search: a
rule is about the action, not the harness.
command (str) — the command about to run; the semantic query.
Absent or blank → empty, no search.
repo (optional) — working repo remote, resolved to the bound
project exactly as /retrieve and /prior-art.
exclude_rule_ids (opt) — comma-separated rule ids already surfaced
this session. SHARED with /prior-art's
ledger on purpose: one session keeps one
list, so a rule named by either arm is not
re-offered by the other.
"""
tool = (request.args.get("tool") or "tool").strip()
command = request.args.get("command") or ""
project_id, _repo, _unbound = await _project_scope()
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
result = await plugin_ctx_svc.build_tool_rule_hint(
g.user.id, tool, command,
project_id=project_id, exclude_rule_ids=exclude_rule_ids,
)
return jsonify(result)
+1 -1
View File
@@ -99,7 +99,7 @@ async def create_project_route():
@login_required
async def decide_inception_route(project_id: int):
"""Record (or re-record) what a project inherits — milestone 297.
Body: the choices object {subscribe_rulebooks,
Body: the choices object {exclude_always_on_rulebooks, subscribe_rulebooks,
design_system_id, seed_systems}; owner-only."""
uid = get_current_user_id()
data = await request.get_json() or {}
+34 -70
View File
@@ -10,9 +10,6 @@ from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
import scribe.services.rulebooks as rulebooks_svc
from scribe.services.trash import delete as trash_delete
from scribe.services.rule_usage import (
empty_rule_usage, record_rule_pulled, usage_for_rules,
)
rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
@@ -55,7 +52,7 @@ async def get_rulebook(rulebook_id: int):
@login_required
async def update_rulebook(rulebook_id: int):
data = await request.get_json() or {}
fields = {k: v for k, v in data.items() if k in ("title", "description")}
fields = {k: v for k, v in data.items() if k in ("title", "description", "always_on")}
rb = await rulebooks_svc.update_rulebook(rulebook_id, get_current_user_id(), **fields)
if rb is None:
return jsonify({"error": "rulebook not found"}), 404
@@ -139,24 +136,13 @@ async def list_rules():
except ValueError:
return jsonify({"error": "rulebook_id, topic_id, project_id must be integers"}), 400
uid = get_current_user_id()
rows = await rulebooks_svc.list_rules(
user_id=uid,
user_id=get_current_user_id(),
rulebook_id=rulebook_id,
topic_id=topic_id,
project_id=project_id,
)
items = [r.to_dict() for r in rows]
# One aggregate for the whole page — a per-row lookup here would be N+1 by
# construction, the same reason the snippet list does it this way. Every
# row gets the key, zero-filled, so the UI renders "never surfaced" rather
# than having to treat a missing field as a state. That matters more here
# than for snippets: every rule on every install predates this table, so
# for a while the zero-filled shape IS the common case.
usage = await usage_for_rules([int(it["id"]) for it in items])
for it in items:
it["usage"] = usage.get(int(it["id"]), empty_rule_usage())
return jsonify({"rules": items})
return jsonify({"rules": [r.to_dict() for r in rows]})
@rulebooks_bp.post("/rulebook-topics/<int:topic_id>/rules")
@@ -177,11 +163,7 @@ async def create_rule(topic_id: int):
how_to_apply=data.get("how_to_apply", ""),
order_index=data.get("order_index", 0),
when_to_apply=data.get("when_to_apply", ""),
# The human door carries `kind` too, and without the MCP door's
# required provenance: an operator editing their own preference
# owes nobody an explanation. That requirement is about auditing
# what the AGENT changed, not what they did themselves.
kind=data.get("kind", "rule"),
tier=data.get("tier", "always_on"),
arose_from_id=data.get("arose_from_id", 0) or 0,
verify_with=data.get("verify_with", ""),
expires_when=data.get("expires_when", ""),
@@ -200,11 +182,6 @@ async def get_rule(rule_id: int):
rule = await rulebooks_svc.get_rule(rule_id, uid)
if rule is None:
return jsonify({"error": "rule not found"}), 404
# `rest_` rather than `mcp_`, and the prefix is load-bearing: "is this rule
# dead weight?" is served by any pull, but "did that injected hint land?"
# — the question this arm exists to answer — is served by AGENT pulls only.
# A person clicking through the rule list says nothing about the hint.
record_rule_pulled(user_id=uid, rule_id=int(rule.id), source="rest_rule")
return jsonify(await rulebooks_svc.rule_detail(uid, rule))
@@ -216,7 +193,7 @@ async def update_rule(rule_id: int):
fields = {
k: v for k, v in data.items()
if k in ("title", "statement", "why", "how_to_apply", "order_index",
"when_to_apply", "kind", "arose_from_id",
"when_to_apply", "tier", "arose_from_id",
"verify_with", "expires_when")
}
# No clear_fields here: a form sends "" for an emptied input, and the
@@ -229,42 +206,6 @@ async def update_rule(rule_id: int):
return jsonify(await rulebooks_svc.rule_detail(uid, rule, data.get("system_ids")))
@rulebooks_bp.get("/rules/<int:rule_id>/versions")
@login_required
async def list_rule_versions(rule_id: int):
"""A rule's edit history, newest first.
Listing form only — a rule's `statement` and `why` run to thousands of
characters, so a history list carrying every field would be unreadable
and expensive to send. Open one for the text.
"""
uid = get_current_user_id()
versions = await rulebooks_svc.list_rule_versions(rule_id, uid)
if versions is None:
return jsonify({"error": "rule not found"}), 404
return jsonify({
"versions": [v.to_dict(include_text=False) for v in versions],
})
@rulebooks_bp.get("/rules/<int:rule_id>/versions/<int:version_id>")
@login_required
async def get_rule_version(rule_id: int, version_id: int):
"""One snapshot in full — what the rule said before that edit."""
uid = get_current_user_id()
version = await rulebooks_svc.get_rule_version(rule_id, version_id, uid)
if version is None:
return jsonify({"error": "version not found"}), 404
return jsonify(version.to_dict(include_text=True))
# NO restore route, deliberately (milestone 323). A note version can be
# restored; a binding instruction should not be revertible in one click.
# Putting a rewrite back goes through update_rule, which takes its own
# snapshot and leaves the undo in the history like any other edit — a silent
# revert would erase the only record of why the rewrite happened.
@rulebooks_bp.post("/rules/<int:rule_id>/relations")
@login_required
async def relate_rules(rule_id: int):
@@ -394,6 +335,32 @@ async def unsuppress_project_topic(project_id: int, topic_id: int):
return "", 204
@rulebooks_bp.post("/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>")
@login_required
async def exclude_project_rulebook(project_id: int, rulebook_id: int):
"""Opt the project out of a whole always-on rulebook (milestone 297)."""
try:
await rulebooks_svc.exclude_always_on_rulebook_for_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
)
except ValueError as exc:
msg = str(exc)
return jsonify({"error": msg}), (400 if "not always-on" in msg else 404)
return "", 204
@rulebooks_bp.delete("/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>")
@login_required
async def include_project_rulebook(project_id: int, rulebook_id: int):
try:
await rulebooks_svc.include_always_on_rulebook_for_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.post("/projects/<int:project_id>/rules")
@login_required
async def create_project_rule(project_id: int):
@@ -413,11 +380,7 @@ async def create_project_rule(project_id: int):
how_to_apply=data.get("how_to_apply", ""),
order_index=data.get("order_index", 0),
when_to_apply=data.get("when_to_apply", ""),
# The human door carries `kind` too, and without the MCP door's
# required provenance: an operator editing their own preference
# owes nobody an explanation. That requirement is about auditing
# what the AGENT changed, not what they did themselves.
kind=data.get("kind", "rule"),
tier=data.get("tier", "always_on"),
arose_from_id=data.get("arose_from_id", 0) or 0,
verify_with=data.get("verify_with", ""),
expires_when=data.get("expires_when", ""),
@@ -436,7 +399,7 @@ async def create_project_rule(project_id: int):
async def rules_due_for_verification():
"""Rules that carry a check, oldest verification first, never-checked top.
Query params: older_than_days, never_only. A rule with no
Query params: older_than_days, tier, never_only. A rule with no
`verify_with` never appears — it is a decision, not a fact.
"""
uid = get_current_user_id()
@@ -449,6 +412,7 @@ async def rules_due_for_verification():
rules = await rulebooks_svc.rules_due_for_verification(
uid,
older_than_days=older,
tier=args.get("tier", ""),
never_only=args.get("never_only", "").lower() in ("1", "true", "yes"),
)
except ValueError as exc:
-5
View File
@@ -44,22 +44,17 @@ async def search_route():
project_id = request.args.get("project_id", type=int)
t0 = time.perf_counter()
report: dict = {}
results = await semantic_search_notes(
uid, q, limit=limit, is_task=is_task, threshold=_REST_SEARCH_THRESHOLD,
project_id=project_id, system_id=system_id,
# The user typed this, so it reaches everything they may read.
scope="read",
report=report,
)
record_retrieval(
user_id=uid, source="rest_search", query=q,
threshold=_REST_SEARCH_THRESHOLD, limit=limit,
project_id=project_id, is_task=is_task, results=results,
duration_ms=(time.perf_counter() - t0) * 1000.0,
best_available=report.get("best_available_score"),
best_available_id=report.get("best_available_id"),
searched=bool(report.get("searched", True)),
)
owners = await owner_names_for(
{int(note.user_id) for _s, note in results if note.user_id != uid}

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