Compare commits
86
Commits
0e5aed58a9
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
277f5df515 | ||
|
|
ab14f783e1 | ||
|
|
1cfbf43ccd | ||
|
|
a165483b92 | ||
|
|
e7c1af32a0 | ||
|
|
277aea58e4 | ||
|
|
5e19a1b028 | ||
|
|
7a2aff7bc1 | ||
|
|
0808e8259a | ||
|
|
950c93c5d4 | ||
|
|
21a5831479 | ||
|
|
dd1e6e2645 | ||
|
|
c3ecdf0972 | ||
|
|
1a34363059 | ||
|
|
30d87e461a | ||
|
|
8be555d6dd | ||
|
|
48804c437d | ||
|
|
154a5de13e | ||
|
|
2ee24b9d2b | ||
|
|
8b9b3a1d9b | ||
|
|
6627cfc2f0 | ||
|
|
238510080e | ||
|
|
8901c904a9 | ||
|
|
70761b16d9 | ||
|
|
8f7f447fda | ||
|
|
111eef7e30 | ||
|
|
8826be7a91 | ||
|
|
e029a7db64 | ||
|
|
9bb59b73ba | ||
|
|
f5a3643da8 | ||
|
|
64cb719a12 | ||
|
|
f1896bfe9d | ||
|
|
ea972ac3f7 | ||
|
|
0d4b155699 | ||
|
|
05da26eb24 | ||
|
|
70d84fbfd7 | ||
|
|
9d8104f7a5 | ||
|
|
7827b4ce63 | ||
|
|
69ce7afc45 | ||
|
|
7985f8c7d7 | ||
|
|
efabba58dd | ||
|
|
5c9bb40777 | ||
|
|
a8b2040216 | ||
|
|
0704988528 | ||
|
|
255c43a8fe | ||
|
|
6fa66f202b | ||
|
|
7a0dc93270 | ||
|
|
9006affda8 | ||
|
|
9657478500 | ||
|
|
1d65e98ac2 | ||
|
|
1ec44071d2 | ||
|
|
b51621fca7 | ||
|
|
8489206224 | ||
|
|
4736a0a0ba | ||
|
|
700ef20eb0 | ||
|
|
b134fe9aa1 | ||
|
|
a6ef3a6a5a | ||
|
|
2263fd04a4 | ||
|
|
2065781302 | ||
|
|
454c617ca0 | ||
|
|
f80401d58e | ||
|
|
d0a2733cb6 | ||
|
|
ce1376edc9 | ||
|
|
0c74dc8275 | ||
|
|
a0b54ff6a3 | ||
|
|
16805ca22c | ||
|
|
63036ed52e | ||
|
|
2e39dca9cf | ||
|
|
69d93898d9 | ||
|
|
5aabc31ee7 | ||
|
|
4be1eaecf6 | ||
|
|
e2e64b94c0 | ||
|
|
3345be84d1 | ||
|
|
35c632f834 | ||
|
|
b97f57ee7f | ||
|
|
874f7cacdb | ||
|
|
91b34619f9 | ||
|
|
3d4f5be711 | ||
|
|
15659e2c57 | ||
|
|
88e9c0b0bd | ||
|
|
c83bedf3be | ||
|
|
9d7485df2d | ||
|
|
410d616c22 | ||
|
|
469b43f222 | ||
|
|
c61925be76 | ||
|
|
e08e999406 |
@@ -46,8 +46,6 @@ 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
|
||||
@@ -279,6 +277,21 @@ 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
|
||||
@@ -289,8 +302,9 @@ 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 (busybox sh — the runner
|
||||
# default — has no bash /dev/tcp, so use Python).
|
||||
# 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.
|
||||
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
||||
import socket, sys, time
|
||||
for _ in range(30):
|
||||
@@ -327,6 +341,14 @@ 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
|
||||
@@ -339,7 +361,27 @@ jobs:
|
||||
# the runner log on commit 2a374d9.
|
||||
run: |
|
||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
||||
BUILD_VERSION="dev"
|
||||
|
||||
# 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"
|
||||
case "${{ github.ref }}" in
|
||||
refs/heads/dev)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||
@@ -348,15 +390,17 @@ jobs:
|
||||
# main IS the production line: publish :latest (plus the :<sha>
|
||||
# set above). No separate :main tag.
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
||||
BUILD_VERSION="main"
|
||||
CHANNEL="stable"
|
||||
;;
|
||||
refs/tags/*)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||
BUILD_VERSION="${{ github.ref_name }}"
|
||||
CHANNEL="stable"
|
||||
;;
|
||||
esac
|
||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "build_name=$BUILD_NAME" >> $GITHUB_OUTPUT
|
||||
echo "build_key=$BUILD_KEY" >> $GITHUB_OUTPUT
|
||||
echo "channel=$CHANNEL" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Free disk space
|
||||
# Self-hosted runner housekeeping. Two-step cleanup:
|
||||
@@ -386,7 +430,15 @@ jobs:
|
||||
push: true
|
||||
provenance: false
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
|
||||
# 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 }}
|
||||
# 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
|
||||
|
||||
+21
-2
@@ -41,10 +41,29 @@ COPY alembic/ alembic/
|
||||
# Ensure Python finds the source tree (where static files live) before site-packages
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
# Version is injected at build time via --build-arg BUILD_VERSION=YY.MM.DD.N
|
||||
# Falls back to "dev" for local / untagged builds
|
||||
# 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.
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG BUILD_KEY=
|
||||
ARG BUILD_CHANNEL=
|
||||
ARG BUILD_COMMIT=
|
||||
ENV APP_VERSION=$BUILD_VERSION
|
||||
ENV APP_BUILD_KEY=$BUILD_KEY
|
||||
ENV APP_CHANNEL=$BUILD_CHANNEL
|
||||
ENV APP_COMMIT=$BUILD_COMMIT
|
||||
|
||||
EXPOSE 5000
|
||||
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: build up down logs health migrate lint typecheck test fmt
|
||||
.PHONY: build up down logs health migrate lint typecheck test fmt mint-plugin
|
||||
|
||||
# --- Docker ---
|
||||
|
||||
@@ -36,3 +36,12 @@ 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
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""a rule can carry its own check — verify_with, expires_when, verified_at
|
||||
(milestone 312 step 1)
|
||||
|
||||
Revision ID: 0090
|
||||
Revises: 0089
|
||||
Create Date: 2026-08-27
|
||||
|
||||
A rulebook holds two kinds of row in one table. A NORM is a decision: it has
|
||||
no truth value, and it changes only when its author changes it — which they
|
||||
know they did. A CONSTRAINT is a fact about someone else's software: a
|
||||
runner's shell, a bot's config, a tool that exists. Nobody is present when
|
||||
that goes false.
|
||||
|
||||
Milestone 307's rulebook audit found nine stale sites. Every one was a
|
||||
constraint; not one norm had rotted. One of them had been telling every
|
||||
session to skip database-backed tests for weeks while the integration lane
|
||||
sat green in the workflow.
|
||||
|
||||
Three nullable columns, so a rule can say how to check itself:
|
||||
|
||||
- `verify_with` — how to tell whether this is still true. A command, a path,
|
||||
a URL, a query. Prose is allowed; something runnable is better.
|
||||
- `expires_when` — the STATE under which it stops being true. Deliberately
|
||||
not a date: constraints do not expire on a schedule, they expire when the
|
||||
world underneath them moves.
|
||||
- `verified_at` — when the check last passed. NULL means never checked, and
|
||||
sorts FIRST in the sweep: unexamined outranks examined-long-ago.
|
||||
|
||||
All three nullable and all three optional, because most rules should set
|
||||
none of them. A null `verify_with` is not an omission — it is the honest
|
||||
marker of "this one is a decision, and there is nothing to go and check."
|
||||
That signal only works if the field stays empty wherever it belongs empty.
|
||||
|
||||
No CHECK constraint is involved, so rule 36 does not apply here. Nothing is
|
||||
backfilled: a migration cannot invent a check any more than 0088 could
|
||||
invent a trigger.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0090"
|
||||
down_revision = "0089"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("rules", sa.Column("verify_with", sa.Text(), nullable=True))
|
||||
op.add_column("rules", sa.Column("expires_when", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"rules",
|
||||
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# No index on (verify_with, verified_at). The sweep this exists for reads
|
||||
# an operator's whole rulebook — hundreds of rows, not millions — and runs
|
||||
# when a human asks for it, never on a request path. An index here would
|
||||
# be maintained on every rule write to serve a query that a sequential
|
||||
# scan answers instantly.
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("rules", "verified_at")
|
||||
op.drop_column("rules", "expires_when")
|
||||
op.drop_column("rules", "verify_with")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""task_kind gains 'spike' — the investigation, not the change
|
||||
(milestone 312 step 5)
|
||||
|
||||
Revision ID: 0091
|
||||
Revises: 0090
|
||||
Create Date: 2026-08-27
|
||||
|
||||
A spike is a task shape the others cannot hold. `work` ships a change;
|
||||
`issue` fixes something broken. A spike is time-boxed and its output is
|
||||
KNOWLEDGE — it succeeds by producing an answer, and nothing ships at the
|
||||
end of it. "Find out whether the runner can be given a bash shell" is not
|
||||
work, and filing it as work makes a finished investigation look like an
|
||||
abandoned change.
|
||||
|
||||
It is the record a failed check asks for. Milestone 312 gave rules a
|
||||
`verify_with`; when one of those fails, the rule is wrong and the next move
|
||||
is often to go and find out what replaced it. `notes.arose_from_id` already
|
||||
exists (0065), so that constraint -> spike link needs no further schema.
|
||||
|
||||
Rule 36: `task_kind` is gated by a CHECK whitelist, so the value and the
|
||||
widened constraint land in the SAME migration — DROP then ADD, exactly as
|
||||
0065 did when it introduced 'issue'. Adding the value and constraining it
|
||||
later leaves a window where the database accepts anything.
|
||||
|
||||
'plan' stays in the list though it is retired (plans are milestones since
|
||||
0066): historical plan-tasks still carry it, and dropping it from the
|
||||
whitelist would make old rows unwritable.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0091"
|
||||
down_revision = "0090"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# One tuple so the upgrade and the downgrade cannot disagree about what the
|
||||
# list was on either side of this migration.
|
||||
_KINDS_AFTER = ("work", "plan", "issue", "spike")
|
||||
_KINDS_BEFORE = ("work", "plan", "issue")
|
||||
|
||||
|
||||
# Restated rather than imported from 0088, which has the same helper. A
|
||||
# migration is a snapshot: it must keep working when the code around it has
|
||||
# moved on, so it never imports from live modules or from its siblings. Six
|
||||
# duplicated lines are the price of that, and the cheap half of the bargain.
|
||||
def _in_list(values: tuple[str, ...]) -> str:
|
||||
return "task_kind IN (" + ", ".join(f"'{v}'" for v in values) + ")"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
|
||||
op.create_check_constraint(
|
||||
"notes_task_kind_check", "notes", _in_list(_KINDS_AFTER),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Any row already filed as a spike would violate the narrowed constraint,
|
||||
# so they are demoted to 'work' first. Lossy and deliberately so: the
|
||||
# alternative is a downgrade that fails on real data, which is worse than
|
||||
# a downgrade that says what it did.
|
||||
op.execute("UPDATE notes SET task_kind = 'work' WHERE task_kind = 'spike'")
|
||||
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
|
||||
op.create_check_constraint(
|
||||
"notes_task_kind_check", "notes", _in_list(_KINDS_BEFORE),
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,83 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,86 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""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")
|
||||
+19
-6
@@ -7,12 +7,20 @@ import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { apiGet, apiPut } from "@/api/client";
|
||||
import { apiPut } from "@/api/client";
|
||||
import { fetchVersion } from "@/api/version";
|
||||
|
||||
useTheme();
|
||||
|
||||
const router = useRouter();
|
||||
const appVersion = ref("dev");
|
||||
// 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 authStore = useAuthStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
|
||||
@@ -119,10 +127,12 @@ onMounted(async () => {
|
||||
startAppServices();
|
||||
}
|
||||
try {
|
||||
const data = await apiGet<{ version: string }>("/api/version");
|
||||
appVersion.value = data.version;
|
||||
appVersion.value = (await fetchVersion()).version;
|
||||
} catch {
|
||||
// silent — version display is non-critical
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -151,7 +161,10 @@ onUnmounted(() => {
|
||||
<div id="main-content" class="app-content">
|
||||
<router-view />
|
||||
</div>
|
||||
<footer class="app-footer">v{{ appVersion }}</footer>
|
||||
<footer class="app-footer">
|
||||
<span v-if="appVersion">v{{ appVersion }}</span>
|
||||
<span v-else-if="appVersionFailed">version unknown</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard shortcuts overlay -->
|
||||
|
||||
+127
-32
@@ -52,41 +52,121 @@ export function apiErrorMessage(e: unknown, fallback: string): string {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path);
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
return handleResponse<T>(res, path);
|
||||
}
|
||||
|
||||
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);
|
||||
/** 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 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 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 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 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 apiDelete(path: string): Promise<void> {
|
||||
const res = await fetch(path, { method: "DELETE" });
|
||||
return handleResponse<void>(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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -221,7 +301,14 @@ export function apiSSEStream(
|
||||
}
|
||||
|
||||
const done = (async () => {
|
||||
const res = await fetch(path, { headers, signal: combinedSignal });
|
||||
// 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();
|
||||
}
|
||||
if (!res.ok) {
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
@@ -318,11 +405,19 @@ export async function apiStreamPost(
|
||||
body: unknown,
|
||||
onChunk: (data: Record<string, unknown>) => void
|
||||
): Promise<void> {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
|
||||
const connect = connectDeadline(new AbortController().signal);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(body),
|
||||
signal: connect.signal,
|
||||
});
|
||||
} finally {
|
||||
connect.settle();
|
||||
}
|
||||
if (!res.ok) {
|
||||
let errBody: Record<string, unknown> = {};
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RecordUsage } from "@/types/usage";
|
||||
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
|
||||
/** How a rule reaches a session (milestone 307). */
|
||||
@@ -55,6 +57,16 @@ export interface Rule {
|
||||
tier: RuleTier;
|
||||
why: string;
|
||||
how_to_apply: string;
|
||||
/**
|
||||
* How to check the rule is still true, and the state that ends it. Set
|
||||
* only on a rule that asserts a fact about something outside the
|
||||
* operator's control; empty on a rule that is a decision, which is most
|
||||
* of them. Empty is meaningful, not missing.
|
||||
*/
|
||||
verify_with: string;
|
||||
expires_when: string;
|
||||
/** When the check last passed. Null means never checked. */
|
||||
verified_at: string | null;
|
||||
/** The note or task that caused this rule, if one was recorded. */
|
||||
arose_from_id: number | null;
|
||||
order_index: number;
|
||||
@@ -80,6 +92,19 @@ export interface RuleHeader {
|
||||
updated_at: string | null;
|
||||
when_to_apply?: string;
|
||||
arose_from_id?: number;
|
||||
/**
|
||||
* Present ONLY on a rule that carries a check — the presence of the key
|
||||
* is itself the signal that this rule asserts a fact that can go false.
|
||||
* A date (YYYY-MM-DD), or the literal "never".
|
||||
*/
|
||||
last_verified?: string;
|
||||
/**
|
||||
* Surfaced-vs-opened counts from `rule_usage_events` (milestone 333).
|
||||
* Zero-filled by the list route, so a rule predating the table reads as
|
||||
* "never surfaced" rather than as a missing field — which for a while is
|
||||
* every rule on every install.
|
||||
*/
|
||||
usage?: RecordUsage;
|
||||
}
|
||||
|
||||
export interface ApplicableRules {
|
||||
@@ -170,7 +195,14 @@ export async function getRule(id: number): Promise<Rule> {
|
||||
return apiGet(`/api/rules/${id}`);
|
||||
}
|
||||
|
||||
/** The fields both write paths accept. `system_ids` REPLACES a rule's areas. */
|
||||
/**
|
||||
* The fields both write paths accept. `system_ids` REPLACES a rule's areas.
|
||||
*
|
||||
* Sending "" for a nullable text field CLEARS it here — the server maps an
|
||||
* empty string to NULL, so an emptied form input does what it looks like it
|
||||
* does. (The MCP door reads "" as "leave unchanged" and needs an explicit
|
||||
* clear_fields list instead; the two idioms reach the same state.)
|
||||
*/
|
||||
export interface RuleWrite {
|
||||
title: string;
|
||||
statement: string;
|
||||
@@ -181,6 +213,8 @@ export interface RuleWrite {
|
||||
order_index: number;
|
||||
system_ids: number[];
|
||||
arose_from_id: number | null;
|
||||
verify_with: string;
|
||||
expires_when: string;
|
||||
}
|
||||
|
||||
export async function createRule(topicId: number, data: Partial<RuleWrite> & { title: string; statement: string }): Promise<Rule> {
|
||||
@@ -203,6 +237,49 @@ 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;
|
||||
tier?: 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}`);
|
||||
}
|
||||
@@ -256,3 +333,59 @@ export async function includeAlwaysOnRulebook(projectId: number, rulebookId: num
|
||||
await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* One row of the staleness sweep. Unlike RuleHeader this carries the CHECK
|
||||
* in full — the reader is about to go and run it, so the text is the point
|
||||
* of the payload rather than the bloat a listing avoids.
|
||||
*/
|
||||
export interface RuleVerificationRow {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
tier: RuleTier;
|
||||
topic_id: number | null;
|
||||
project_id: number | null;
|
||||
when_to_apply: string;
|
||||
verify_with: string;
|
||||
expires_when: string;
|
||||
/** A date (YYYY-MM-DD), or the literal "never". */
|
||||
last_verified: string | null;
|
||||
/** Null when never verified — "never" is not zero days ago. */
|
||||
days_since_verified: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules asserting a fact that may have gone false, oldest verification
|
||||
* first, never-checked at the top. Rules without a check never appear:
|
||||
* they are decisions, and there is nothing to go and check.
|
||||
*
|
||||
* Not filterable by project — a project reaches rules through project
|
||||
* scope, subscriptions, always-on rulebooks and exclusions, and a filter
|
||||
* missing one of those paths would under-report.
|
||||
*/
|
||||
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}` : ""}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a rule's check was RUN, and what it said.
|
||||
*
|
||||
* `stillTrue: false` writes nothing on purpose — a rule whose check failed
|
||||
* is not in a recordable state, it is wrong — so it stays at the top of the
|
||||
* sweep until someone corrects or retires it.
|
||||
*/
|
||||
export async function markRuleVerified(
|
||||
id: number, stillTrue = true,
|
||||
): Promise<Rule & { verified: boolean }> {
|
||||
return apiPost(`/api/rules/${id}/verify`, { still_true: stillTrue });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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
|
||||
@@ -50,15 +52,11 @@ export interface Snippet {
|
||||
owner?: string | null;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
/** 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;
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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 });
|
||||
}
|
||||
@@ -351,3 +351,29 @@
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
border: 1px solid var(--fs-error);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-error);
|
||||
color: var(--fs-error-fg);
|
||||
}
|
||||
.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);
|
||||
color: var(--fs-error-fg);
|
||||
}
|
||||
.diff-insert {
|
||||
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
|
||||
color: var(--fs-success);
|
||||
color: var(--fs-success-fg);
|
||||
}
|
||||
.diff-equal {
|
||||
color: var(--fs-text-tertiary);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/* Shared by the three rules panes (RulebookListPane, RuleListPane,
|
||||
RulebookDetailPane): the pane surface and its heading. Load with
|
||||
/* Shared by the rules panes (RulebookListPane, RuleListPane,
|
||||
RulebookDetailPane, RuleSweepPane): the pane surface, its heading, and the
|
||||
title chip. Counting them in this comment went stale the first time a
|
||||
fourth was added, so it no longer does. Load with
|
||||
<style src="@/assets/rules-shared.css" /> beside the component's own
|
||||
scoped block; never restate these there (#2903, milestone 299). */
|
||||
.pane {
|
||||
@@ -13,3 +15,19 @@
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
.form-buttons { display: flex; gap: 0.5rem; }
|
||||
|
||||
/* A small marker beside a rule's title. Two of these appeared within one
|
||||
milestone (tier, then verification) and were byte-identical; a third would
|
||||
have drifted. The pane's italic serif title is inherited by anything inside
|
||||
it, so the chip resets family and style explicitly. */
|
||||
.rule-chip {
|
||||
margin-left: 0.4rem;
|
||||
font-family: var(--fs-font-body);
|
||||
font-style: normal;
|
||||
font-size: 0.62rem;
|
||||
color: var(--fs-text-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.05rem 0.4rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@
|
||||
file used to read, and it is deliberate: the light palette was never specified
|
||||
by any rule, so it is recorded as a departure rather than as the default.
|
||||
|
||||
The -fg tokens are a badge's TEXT colour, added because the ladder used its
|
||||
raw hue as text on a 12% tint of the same hue — measured 1.60-2.97:1 on the
|
||||
dark palette against the kit's AA floor of 4.5. Each is the hue mixed toward
|
||||
--fs-text-primary until it clears 4.5:1 worst-case over surface-raised and
|
||||
surface-hover in BOTH modes. Mixing toward that token is what makes one
|
||||
declaration cover both: it inverts, so the text follows the mode.
|
||||
|
||||
Only 12 tokens differ between modes. Everything else — spacing, type, motion,
|
||||
radius, and every derived colour — is stated once, because a value built with
|
||||
var() resolves where it is USED, not where it is written.
|
||||
@@ -24,6 +31,7 @@
|
||||
--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);
|
||||
@@ -78,10 +86,13 @@
|
||||
/* priority */
|
||||
--fs-priority-low: var(--fs-info);
|
||||
--fs-priority-low-bg: color-mix(in srgb, var(--fs-priority-low) 12%, transparent);
|
||||
--fs-priority-low-fg: color-mix(in srgb, var(--fs-priority-low) 45%, var(--fs-text-primary)); /* Badge TEXT for low priority — the readable partner of the -bg tint */
|
||||
--fs-priority-medium: var(--fs-warning);
|
||||
--fs-priority-medium-bg: color-mix(in srgb, var(--fs-priority-medium) 12%, transparent);
|
||||
--fs-priority-medium-fg: color-mix(in srgb, var(--fs-priority-medium) 55%, var(--fs-text-primary)); /* Badge TEXT for medium priority */
|
||||
--fs-priority-high: var(--fs-error);
|
||||
--fs-priority-high-bg: color-mix(in srgb, var(--fs-priority-high) 12%, transparent);
|
||||
--fs-priority-high-fg: color-mix(in srgb, var(--fs-priority-high) 55%, var(--fs-text-primary)); /* Badge TEXT for high priority */
|
||||
|
||||
/* radius */
|
||||
--fs-radius-sm: 4px; /* pills, tags, code spans */
|
||||
@@ -92,8 +103,11 @@
|
||||
|
||||
/* 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 */
|
||||
|
||||
@@ -116,12 +130,16 @@
|
||||
/* status */
|
||||
--fs-status-todo: var(--fs-border-color);
|
||||
--fs-status-todo-bg: color-mix(in srgb, var(--fs-status-todo) 12%, transparent);
|
||||
--fs-status-todo-fg: color-mix(in srgb, var(--fs-status-todo) 40%, var(--fs-text-primary)); /* Badge TEXT for a not-started task */
|
||||
--fs-status-in-progress: var(--fs-accent);
|
||||
--fs-status-in-progress-bg: color-mix(in srgb, var(--fs-status-in-progress) 12%, transparent);
|
||||
--fs-status-in-progress-fg: color-mix(in srgb, var(--fs-status-in-progress) 45%, var(--fs-text-primary)); /* Badge TEXT for a task underway */
|
||||
--fs-status-done: var(--fs-success);
|
||||
--fs-status-done-bg: color-mix(in srgb, var(--fs-status-done) 12%, transparent);
|
||||
--fs-status-done-fg: color-mix(in srgb, var(--fs-status-done) 50%, var(--fs-text-primary)); /* Badge TEXT for a completed task */
|
||||
--fs-overdue: var(--fs-error);
|
||||
--fs-status-cancelled: var(--fs-text-tertiary); /* set aside, not failed */
|
||||
--fs-status-cancelled-fg: color-mix(in srgb, var(--fs-status-cancelled) 60%, var(--fs-text-primary)); /* Badge TEXT for a cancelled task */
|
||||
|
||||
/* surface */
|
||||
--fs-surface-page: #14171A; /* page bg, deepest surface */
|
||||
@@ -134,7 +152,9 @@
|
||||
/* 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 */
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.ctx-crumb-project {
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||
text-decoration: none;
|
||||
|
||||
@@ -206,7 +206,7 @@ router.afterEach(() => {
|
||||
background: var(--fs-accent-soft);
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
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);
|
||||
color: var(--fs-accent-fg);
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
|
||||
@@ -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);
|
||||
color: var(--fs-error-fg);
|
||||
}
|
||||
|
||||
.diff-insert {
|
||||
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
|
||||
color: var(--fs-success);
|
||||
color: var(--fs-success-fg);
|
||||
}
|
||||
|
||||
.diff-equal {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import type { DiffLine } from "@/composables/useAssist";
|
||||
import { computeDiff, type DiffLine } from "@/utils/diff";
|
||||
import { fmtStamp } from "@/utils/dateFormat";
|
||||
|
||||
interface NoteVersion {
|
||||
@@ -33,28 +33,8 @@ const loadingDetail = ref(false);
|
||||
|
||||
const diff = computed<DiffLine[]>(() => {
|
||||
if (!selectedVersion.value?.body) return [];
|
||||
const a = props.currentBody;
|
||||
const b = 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;
|
||||
return computeDiff(props.currentBody, selectedVersion.value.body);
|
||||
});
|
||||
|
||||
async function loadVersions() {
|
||||
|
||||
@@ -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);
|
||||
color: var(--fs-error-fg);
|
||||
}
|
||||
.iap-diff-insert {
|
||||
background: color-mix(in srgb, var(--fs-success) 10%, transparent);
|
||||
color: var(--fs-success);
|
||||
color: var(--fs-success-fg);
|
||||
}
|
||||
|
||||
.iap-diff-marker {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A task's KIND, shown on a list row — issue, spike, or a legacy plan.
|
||||
*
|
||||
* Sibling of PriorityBadge, and shaped like it on purpose: same geometry, and
|
||||
* the same rule that the DEFAULT value renders nothing. `work` is most tasks,
|
||||
* so badging it would put a chip on nearly every row and say nothing — the
|
||||
* same reason RuleListPane marks only `conditional`.
|
||||
*
|
||||
* Kind is not status. A task can be an in-progress issue or a done spike;
|
||||
* this answers "what kind of work is this", never "how is it going".
|
||||
*/
|
||||
import type { TaskKind } from "@/types/note";
|
||||
|
||||
const props = defineProps<{ kind?: TaskKind | null }>();
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
issue: "Issue",
|
||||
spike: "Spike",
|
||||
plan: "Plan",
|
||||
};
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
issue: "Corrective work — something was broken",
|
||||
spike: "Time-boxed investigation — the output is an answer, not a change",
|
||||
plan: "Legacy plan-task; plans are milestones now",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="props.kind && LABELS[props.kind]"
|
||||
:class="['kind-badge', `kind-${props.kind}`]"
|
||||
:title="TITLES[props.kind]"
|
||||
>{{ LABELS[props.kind] }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kind-badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
/* 500, not the 600 StatusBadge and PriorityBadge use. The house style
|
||||
allows two weights, 400 and 500 — those two predate the constraint and
|
||||
copying them would spread it. */
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Issue and spike are opposite in character — corrective vs exploratory — so
|
||||
they are split by TEMPERATURE, warm against cool, which survives being
|
||||
small and stays distinguishable without relying on reading the word.
|
||||
Neither uses the accent: one accent per app, and kind is not one of the
|
||||
places it is allowed.
|
||||
The text is the hue mixed toward --fs-text-primary rather than the raw
|
||||
semantic colour. Raw fails the contrast floor on the dark palette —
|
||||
measured: warning on its own 12% tint is 2.97:1, well under AA's 4.5.
|
||||
Mixing toward the text token also makes these follow the mode for free,
|
||||
since that token inverts. Measured both ways: issue 5.23:1 dark / 6.68:1
|
||||
light, spike 5.33:1 / 9.26:1. */
|
||||
.kind-issue {
|
||||
background: color-mix(in srgb, var(--fs-warning) 14%, var(--fs-surface-raised));
|
||||
color: color-mix(in srgb, var(--fs-warning) 60%, var(--fs-text-primary));
|
||||
}
|
||||
.kind-spike {
|
||||
background: color-mix(in srgb, var(--fs-info) 14%, var(--fs-surface-raised));
|
||||
color: color-mix(in srgb, var(--fs-info) 50%, var(--fs-text-primary));
|
||||
}
|
||||
/* Retired since 0066 — deliberately hue-free so a legacy row reads as
|
||||
archival rather than as a fourth active kind competing for attention. */
|
||||
.kind-plan {
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -156,7 +156,7 @@ const groups = [
|
||||
|
||||
.md-btn.active {
|
||||
background: color-mix(in srgb, var(--fs-accent) 14%, transparent);
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--fs-accent) 35%, transparent);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<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>
|
||||
@@ -3,6 +3,8 @@ import type { TaskPriority } from "@/types/task";
|
||||
|
||||
const props = defineProps<{
|
||||
priority: TaskPriority;
|
||||
/** Dense surfaces — see StatusBadge. */
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
const labels: Record<TaskPriority, string> = {
|
||||
@@ -16,7 +18,7 @@ const labels: Record<TaskPriority, string> = {
|
||||
<template>
|
||||
<span
|
||||
v-if="props.priority !== 'none'"
|
||||
:class="['priority-badge', `priority-${props.priority}`]"
|
||||
:class="['priority-badge', `priority-${props.priority}`, { compact }]"
|
||||
>
|
||||
{{ labels[props.priority] }}
|
||||
</span>
|
||||
@@ -28,20 +30,28 @@ const labels: Record<TaskPriority, string> = {
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
/* 500 is the heaviest the house style goes — 400 and 500 only. */
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
.compact {
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.7rem;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
.priority-low {
|
||||
background: var(--fs-priority-low-bg);
|
||||
color: var(--fs-priority-low);
|
||||
color: var(--fs-priority-low-fg);
|
||||
}
|
||||
.priority-medium {
|
||||
background: var(--fs-priority-medium-bg);
|
||||
color: var(--fs-priority-medium);
|
||||
color: var(--fs-priority-medium-fg);
|
||||
}
|
||||
.priority-high {
|
||||
background: var(--fs-priority-high-bg);
|
||||
color: var(--fs-priority-high);
|
||||
color: var(--fs-priority-high-fg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -153,7 +153,7 @@ watch(() => [props.projectId, props.designSystemId], run);
|
||||
}
|
||||
|
||||
.pdt-clean {
|
||||
color: var(--fs-status-done);
|
||||
color: var(--fs-status-done-fg);
|
||||
}
|
||||
|
||||
.pdt-summary {
|
||||
@@ -206,12 +206,12 @@ watch(() => [props.projectId, props.designSystemId], run);
|
||||
|
||||
.pdt-tag.unknown {
|
||||
background: var(--fs-priority-high-bg);
|
||||
color: var(--fs-priority-high);
|
||||
color: var(--fs-priority-high-fg);
|
||||
}
|
||||
|
||||
.pdt-tag.local {
|
||||
background: var(--fs-priority-medium-bg);
|
||||
color: var(--fs-priority-medium);
|
||||
color: var(--fs-priority-medium-fg);
|
||||
}
|
||||
|
||||
.pdt-tag.superseded {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A PROJECT's lifecycle state as a pill — active, paused, completed, archived.
|
||||
*
|
||||
* Deliberately not StatusBadge. That component is typed to TaskStatus and
|
||||
* speaks a different vocabulary; these two only ever shared a CSS class name,
|
||||
* which is what made them look like one shape that had drifted (#3132).
|
||||
*
|
||||
* Extracted because ProjectView and ProjectListView really were spelling the
|
||||
* same pill twice, with the differences you get from two hands rather than
|
||||
* two intentions: 0.68rem against 0.7rem, a 14% tint against 15%, one with a
|
||||
* border and one without.
|
||||
*/
|
||||
const props = defineProps<{ status: string }>();
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
active: "Active",
|
||||
paused: "Paused",
|
||||
completed: "Completed",
|
||||
archived: "Archived",
|
||||
};
|
||||
|
||||
const label = (s: string) => LABELS[s] ?? s;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="['project-status', `project-status--${props.status}`]">
|
||||
{{ label(props.status) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.project-status {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: var(--fs-radius-pill);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Text is the hue mixed toward --fs-text-primary, not the raw hue. Both old
|
||||
spellings painted the hue on a 15% tint of itself, which measured 1.61-2.39:1
|
||||
against AA's 4.5 — the same defect the status and priority ladders had, and
|
||||
invisible to the token checker because the background was an inline
|
||||
color-mix rather than a `-bg` token. The checker was widened alongside this.
|
||||
Measured worst-case over raised and hover in both modes: active 4.82:1,
|
||||
paused 4.63:1, completed 4.78:1, archived 4.84:1.
|
||||
No new design tokens: four values used by one component are the kind of
|
||||
growth Scribe's own design-system note warns about ("if this system grows
|
||||
past a handful of tokens, that is worth noticing rather than
|
||||
accommodating"). The derivation is stated once, here. */
|
||||
.project-status--active {
|
||||
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
|
||||
color: color-mix(in srgb, var(--fs-success) 45%, var(--fs-text-primary));
|
||||
}
|
||||
.project-status--paused {
|
||||
background: color-mix(in srgb, var(--fs-warning) 15%, transparent);
|
||||
color: color-mix(in srgb, var(--fs-warning) 55%, var(--fs-text-primary));
|
||||
}
|
||||
.project-status--completed {
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: color-mix(in srgb, var(--fs-accent) 45%, var(--fs-text-primary));
|
||||
}
|
||||
.project-status--archived {
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||
color: color-mix(in srgb, var(--fs-text-tertiary) 55%, var(--fs-text-primary));
|
||||
}
|
||||
</style>
|
||||
@@ -4,13 +4,16 @@ import type { TaskStatus } from "@/types/task";
|
||||
const props = defineProps<{
|
||||
status: TaskStatus;
|
||||
clickable?: boolean;
|
||||
/** Dense surfaces — smaller, unshouted. The canon (#2960) names compact a
|
||||
VARIANT of this component rather than a reason to re-spell it. */
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{ click: [] }>();
|
||||
|
||||
const labels: Record<TaskStatus, string> = {
|
||||
todo: "Todo",
|
||||
in_progress: "In Progress",
|
||||
in_progress: "In progress",
|
||||
done: "Done",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
@@ -18,7 +21,7 @@ const labels: Record<TaskStatus, string> = {
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="['status-badge', `status-${props.status}`, { clickable }]"
|
||||
:class="['status-badge', `status-${props.status}`, { clickable, compact }]"
|
||||
@click="clickable ? $emit('click') : undefined"
|
||||
:role="clickable ? 'button' : undefined"
|
||||
:tabindex="clickable ? 0 : undefined"
|
||||
@@ -33,25 +36,37 @@ const labels: Record<TaskStatus, string> = {
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
/* 500 is the heaviest the house style goes — 400 and 500 only. */
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
/* Text comes from the -fg tokens, which are the hue mixed toward
|
||||
--fs-text-primary until they clear AA. The old spelling darkened the hue
|
||||
with `#000 15%` — a light-mode instinct that made these WORSE on the dark
|
||||
palette, where the surface is already near-black, and a literal besides. */
|
||||
.status-todo {
|
||||
background: color-mix(in srgb, var(--fs-status-todo-bg) 78%, var(--fs-status-todo) 22%);
|
||||
color: color-mix(in srgb, var(--fs-status-todo) 85%, #000 15%);
|
||||
background: var(--fs-status-todo-bg);
|
||||
color: var(--fs-status-todo-fg);
|
||||
}
|
||||
.status-in_progress {
|
||||
background: color-mix(in srgb, var(--fs-status-in-progress-bg) 78%, var(--fs-status-in-progress) 22%);
|
||||
color: color-mix(in srgb, var(--fs-status-in-progress) 85%, #000 15%);
|
||||
background: var(--fs-status-in-progress-bg);
|
||||
color: var(--fs-status-in-progress-fg);
|
||||
}
|
||||
.status-done {
|
||||
background: color-mix(in srgb, var(--fs-status-done-bg) 78%, var(--fs-status-done) 22%);
|
||||
color: color-mix(in srgb, var(--fs-status-done) 85%, #000 15%);
|
||||
background: var(--fs-status-done-bg);
|
||||
color: var(--fs-status-done-fg);
|
||||
}
|
||||
.status-cancelled {
|
||||
background: color-mix(in srgb, var(--fs-surface-raised) 78%, var(--fs-text-tertiary) 22%);
|
||||
color: var(--fs-text-tertiary);
|
||||
background: var(--fs-status-todo-bg);
|
||||
color: var(--fs-status-cancelled-fg);
|
||||
}
|
||||
.compact {
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.7rem;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -554,8 +554,8 @@ async function confirmDelete() {
|
||||
/* The two bases must never look alike — one is mechanical, the other is the
|
||||
reviewer's judgment, and that difference is the whole decision. */
|
||||
.area-basis { font-size: 0.68rem; border-radius: var(--fs-radius-sm); padding: 0.05rem 0.4rem; }
|
||||
.area-basis--exact { background: var(--fs-status-done-bg); color: var(--fs-status-done); }
|
||||
.area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); }
|
||||
.area-basis--exact { background: var(--fs-status-done-bg); color: var(--fs-status-done-fg); }
|
||||
.area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium-fg); }
|
||||
|
||||
.area-offer {
|
||||
display: flex;
|
||||
@@ -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);
|
||||
color: var(--fs-accent-fg);
|
||||
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);
|
||||
color: var(--fs-text-tertiary-fg);
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.45rem;
|
||||
|
||||
@@ -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);
|
||||
color: var(--fs-accent-fg);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task, TaskStatus } from "@/types/task";
|
||||
import StatusBadge from "@/components/StatusBadge.vue";
|
||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
import { renderPreview } from "@/utils/markdown";
|
||||
|
||||
const props = defineProps<{
|
||||
task: Task;
|
||||
compact?: boolean;
|
||||
projectTitle?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
"tag-click": [tag: string];
|
||||
"status-toggle": [id: number, status: TaskStatus];
|
||||
}>();
|
||||
|
||||
const statusCycle: Record<TaskStatus, TaskStatus> = {
|
||||
todo: "in_progress",
|
||||
in_progress: "done",
|
||||
done: "todo",
|
||||
cancelled: "todo",
|
||||
};
|
||||
|
||||
const statusDotClass: Record<TaskStatus, string> = {
|
||||
todo: "dot-todo",
|
||||
in_progress: "dot-in-progress",
|
||||
done: "dot-done",
|
||||
cancelled: "dot-cancelled",
|
||||
};
|
||||
|
||||
const statusTitle: Record<TaskStatus, string> = {
|
||||
todo: "Todo — click to mark In Progress",
|
||||
in_progress: "In Progress — click to mark Done",
|
||||
done: "Done — click to mark Todo",
|
||||
cancelled: "Cancelled — click to mark Todo",
|
||||
};
|
||||
|
||||
function cycleStatus() {
|
||||
emit("status-toggle", props.task.id, statusCycle[props.task.status!]);
|
||||
}
|
||||
|
||||
|
||||
function isOverdue(): boolean {
|
||||
if (!props.task.due_date || props.task.status === "done") return false;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return props.task.due_date < today;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link :to="`/tasks/${task.id}`" :class="['task-card', { compact }]">
|
||||
|
||||
<!-- Compact: single row -->
|
||||
<template v-if="compact">
|
||||
<button
|
||||
:class="['status-dot', statusDotClass[task.status!]]"
|
||||
:title="statusTitle[task.status!]"
|
||||
@click.prevent.stop="cycleStatus"
|
||||
></button>
|
||||
<PriorityBadge :priority="task.priority!" />
|
||||
<span class="task-title-compact">{{ task.title || "Untitled" }}</span>
|
||||
<span v-if="projectTitle" class="project-crumb">{{ projectTitle }}</span>
|
||||
<div class="task-tags-compact">
|
||||
<TagPill
|
||||
v-for="tag in task.tags?.slice(0, 2)"
|
||||
:key="tag"
|
||||
:tag="tag"
|
||||
@click.stop="emit('tag-click', tag)"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="task.due_date" :class="['due-compact', { overdue: isOverdue() }]">
|
||||
{{ task.due_date }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Full: original layout -->
|
||||
<template v-else>
|
||||
<div class="task-top">
|
||||
<StatusBadge
|
||||
:status="task.status!"
|
||||
clickable
|
||||
@click.prevent.stop="cycleStatus"
|
||||
/>
|
||||
<PriorityBadge :priority="task.priority!" />
|
||||
<h3 class="task-title">{{ task.title || "Untitled" }}</h3>
|
||||
</div>
|
||||
<div v-if="task.body" class="task-preview prose" v-html="renderPreview(task.body)"></div>
|
||||
<div class="task-meta">
|
||||
<span v-if="task.due_date" :class="['due-date', { overdue: isOverdue() }]">
|
||||
Due: {{ task.due_date }}
|
||||
</span>
|
||||
<TagPill
|
||||
v-for="tag in task.tags"
|
||||
:key="tag"
|
||||
:tag="tag"
|
||||
@click.stop="emit('tag-click', tag)"
|
||||
/>
|
||||
<span class="timestamp">{{ relativeTime(task.updated_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.task-card {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
border-radius: var(--fs-radius-lg);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--fs-surface-raised);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 6%, transparent);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.task-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 14.0%, transparent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Compact single-row layout */
|
||||
.task-card.compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
}
|
||||
|
||||
/* Status dot */
|
||||
.status-dot {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: transform 0.1s, opacity 0.1s;
|
||||
}
|
||||
.status-dot:hover {
|
||||
transform: scale(1.25);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.dot-todo {
|
||||
background: var(--fs-status-todo);
|
||||
border: 2px solid var(--fs-status-todo);
|
||||
background: transparent;
|
||||
border: 2px solid var(--fs-text-tertiary);
|
||||
}
|
||||
.dot-in-progress {
|
||||
background: var(--fs-status-in-progress);
|
||||
}
|
||||
.dot-done {
|
||||
background: var(--fs-status-done);
|
||||
}
|
||||
.dot-cancelled {
|
||||
background: var(--fs-status-cancelled);
|
||||
}
|
||||
|
||||
.task-title-compact {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.project-crumb {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.1rem 0.4rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-tags-compact {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.due-compact {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.due-compact.overdue {
|
||||
color: var(--fs-error);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Full layout */
|
||||
.task-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.task-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.task-preview {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--fs-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
max-height: 7.5em;
|
||||
overflow: hidden;
|
||||
}
|
||||
.task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.due-date {
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.due-date.overdue {
|
||||
color: var(--fs-overdue);
|
||||
font-weight: 600;
|
||||
}
|
||||
.timestamp {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<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 type { DiffLine } from "@/composables/useAssist";
|
||||
import { computeDiff, type DiffLine } from "@/utils/diff";
|
||||
|
||||
interface NoteVersion {
|
||||
id: number;
|
||||
@@ -31,25 +31,7 @@ const loadingDetail = ref(false);
|
||||
|
||||
const diff = computed<DiffLine[]>(() => {
|
||||
if (!selectedVersion.value?.body) return [];
|
||||
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;
|
||||
return computeDiff(props.currentBody, selectedVersion.value.body);
|
||||
});
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
|
||||
@@ -548,7 +548,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
|
||||
.note-tag-pill {
|
||||
font-size: 0.58rem;
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
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);
|
||||
color: var(--fs-accent-fg);
|
||||
}
|
||||
|
||||
.link-suggest-strip {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { RouterLink } from "vue-router";
|
||||
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||
import KindBadge from "@/components/KindBadge.vue";
|
||||
import type { TaskKind } from "@/types/note";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
||||
@@ -28,6 +30,7 @@ interface Task {
|
||||
due_date: string | null;
|
||||
updated_at: string;
|
||||
body?: string;
|
||||
task_kind?: TaskKind;
|
||||
}
|
||||
|
||||
const tasks = ref<Task[]>([]);
|
||||
@@ -242,6 +245,7 @@ defineExpose({ reload: loadAll });
|
||||
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '○' }}</button>
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
||||
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
||||
</li>
|
||||
@@ -267,6 +271,7 @@ defineExpose({ reload: loadAll });
|
||||
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '○' }}</button>
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
||||
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
||||
</li>
|
||||
@@ -281,7 +286,7 @@ defineExpose({ reload: loadAll });
|
||||
<div v-if="activeTask" class="task-detail">
|
||||
<div class="detail-header">
|
||||
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-text btn-edit-task" title="Open full editor">Edit ↗</RouterLink>
|
||||
<span :class="['status-badge', `status-${activeTask.status}`]" @click="cycleStatus(activeTask, $event)" title="Click to cycle status">
|
||||
<span :class="['status-cycler', `status-${activeTask.status}`]" @click="cycleStatus(activeTask, $event)" title="Click to cycle status">
|
||||
{{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }}
|
||||
</span>
|
||||
<template v-if="deleteConfirmPending">
|
||||
@@ -419,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); }
|
||||
.ms-status-completed { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success); }
|
||||
.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); }
|
||||
|
||||
.task-items {
|
||||
list-style: none;
|
||||
@@ -496,7 +501,10 @@ defineExpose({ reload: loadAll });
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
/* An interactive CYCLER, not a chip: it is clickable, outlined and
|
||||
transparent. It shared a name with the task chip and was never the same
|
||||
shape (#3132). */
|
||||
.status-cycler {
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
@@ -508,8 +516,8 @@ defineExpose({ reload: loadAll });
|
||||
user-select: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
.status-badge.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
|
||||
.status-badge.status-done { border-color: var(--fs-success); color: var(--fs-success); background: color-mix(in srgb, var(--fs-success) 10%, transparent); }
|
||||
.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); }
|
||||
|
||||
.btn-edit-task { margin-left: 0.25rem; }
|
||||
.btn-edit-task:hover { text-decoration: underline; }
|
||||
|
||||
@@ -24,7 +24,10 @@ const allRulebooks = ref<Rulebook[]>([]);
|
||||
const showPicker = ref(false);
|
||||
const expandedRuleIds = ref<Set<number>>(new Set());
|
||||
|
||||
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
|
||||
const ruleDetails = ref<Record<number, {
|
||||
why: string; how_to_apply: string;
|
||||
verify_with: string; expires_when: string; verified_at: string | null;
|
||||
}>>({});
|
||||
|
||||
const showProjectRuleForm = ref(false);
|
||||
const newProjectRule = ref({
|
||||
@@ -67,6 +70,9 @@ async function toggleRuleExpand(ruleId: number) {
|
||||
ruleDetails.value[ruleId] = {
|
||||
why: rule.why || "",
|
||||
how_to_apply: rule.how_to_apply || "",
|
||||
verify_with: rule.verify_with || "",
|
||||
expires_when: rule.expires_when || "",
|
||||
verified_at: rule.verified_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -74,6 +80,11 @@ async function toggleRuleExpand(ruleId: number) {
|
||||
expandedRuleIds.value = new Set(expandedRuleIds.value);
|
||||
}
|
||||
|
||||
/** "never run" reads as a stronger claim than an absent date — and it is. */
|
||||
function checkAge(verifiedAt: string | null): string {
|
||||
return verifiedAt ? `last passed ${verifiedAt.slice(0, 10)}` : "never run";
|
||||
}
|
||||
|
||||
function openInRulesView(rulebookId: number, ruleId?: number) {
|
||||
const query: Record<string, string> = { rb: String(rulebookId) };
|
||||
if (ruleId) query.rule = String(ruleId);
|
||||
@@ -279,6 +290,16 @@ watch(() => props.projectId, load);
|
||||
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||
</div>
|
||||
<!-- Shown only when the rule carries a check. Read-only here: this
|
||||
tab is the project's view of what binds it, and editing a rule
|
||||
belongs on the rulebook surface that owns it. -->
|
||||
<div v-if="ruleDetails[r.id].verify_with">
|
||||
<strong>Check:</strong> {{ ruleDetails[r.id].verify_with }}
|
||||
<span class="rule-check-age">{{ checkAge(ruleDetails[r.id].verified_at) }}</span>
|
||||
</div>
|
||||
<div v-if="ruleDetails[r.id].expires_when">
|
||||
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
|
||||
</div>
|
||||
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
@@ -332,6 +353,13 @@ watch(() => props.projectId, load);
|
||||
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||
</div>
|
||||
<div v-if="ruleDetails[r.id].verify_with">
|
||||
<strong>Check:</strong> {{ ruleDetails[r.id].verify_with }}
|
||||
<span class="rule-check-age">{{ checkAge(ruleDetails[r.id].verified_at) }}</span>
|
||||
</div>
|
||||
<div v-if="ruleDetails[r.id].expires_when">
|
||||
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
|
||||
</div>
|
||||
<button
|
||||
class="edit-link"
|
||||
@click="openInRulesView(r.rulebook_id, r.id)"
|
||||
@@ -428,6 +456,11 @@ ul { list-style: none; padding: 0; margin: 0; }
|
||||
}
|
||||
.rule-head { cursor: pointer; }
|
||||
.rule-title { font-weight: 500; }
|
||||
.rule-check-age {
|
||||
margin-left: var(--fs-space-2);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
|
||||
.rule-detail {
|
||||
margin-top: 0.5rem; padding: 0.5rem;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref, watch, onMounted } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||
import type { RuleTier } from "@/api/rulebooks";
|
||||
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
||||
|
||||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
@@ -16,6 +17,8 @@ const tier = ref<RuleTier>("always_on");
|
||||
const systemIds = ref<number[]>([]);
|
||||
const why = ref("");
|
||||
const howToApply = ref("");
|
||||
const verifyWith = ref("");
|
||||
const expiresWhen = ref("");
|
||||
|
||||
const relations = computed(() => store.currentRule?.relations ?? []);
|
||||
|
||||
@@ -38,6 +41,27 @@ function toggleSystem(id: number) {
|
||||
|
||||
const isCreating = ref(props.ruleId === null);
|
||||
|
||||
// The stored stamp, not the draft: it describes the check that was RUN, and
|
||||
// an unsaved edit to the textarea has not been run against anything.
|
||||
const verifiedAt = computed(() => store.currentRule?.verified_at ?? null);
|
||||
const savedCheck = computed(() => store.currentRule?.verify_with ?? "");
|
||||
// Built here rather than in the template: same shape as the server's
|
||||
// last_verified_label, and it keeps the null-narrowing in TypeScript's reach.
|
||||
const stampLabel = computed(() =>
|
||||
verifiedAt.value ? `Last checked ${verifiedAt.value.slice(0, 10)}` : "Never checked",
|
||||
);
|
||||
const verifying = ref(false);
|
||||
|
||||
async function verify(stillTrue: boolean) {
|
||||
if (props.ruleId === null) return;
|
||||
verifying.value = true;
|
||||
try {
|
||||
await store.verifyRule(props.ruleId, stillTrue);
|
||||
} finally {
|
||||
verifying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (props.ruleId !== null) {
|
||||
await store.fetchRule(props.ruleId);
|
||||
@@ -50,6 +74,8 @@ async function load() {
|
||||
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
||||
why.value = r.why || "";
|
||||
howToApply.value = r.how_to_apply || "";
|
||||
verifyWith.value = r.verify_with || "";
|
||||
expiresWhen.value = r.expires_when || "";
|
||||
}
|
||||
} else {
|
||||
title.value = "";
|
||||
@@ -59,6 +85,8 @@ async function load() {
|
||||
systemIds.value = [];
|
||||
why.value = "";
|
||||
howToApply.value = "";
|
||||
verifyWith.value = "";
|
||||
expiresWhen.value = "";
|
||||
}
|
||||
await canon.fetchCatalog();
|
||||
}
|
||||
@@ -78,6 +106,11 @@ async function save() {
|
||||
system_ids: systemIds.value,
|
||||
why: why.value,
|
||||
how_to_apply: howToApply.value,
|
||||
// Always sent, including empty. The REST door maps "" to NULL, so
|
||||
// clearing a field here actually clears it — the MCP door's "" means
|
||||
// "leave unchanged" and needs an explicit clear_fields list instead.
|
||||
verify_with: verifyWith.value,
|
||||
expires_when: expiresWhen.value,
|
||||
};
|
||||
if (isCreating.value && props.topicId !== null) {
|
||||
await store.createRule(props.topicId, fields);
|
||||
@@ -162,6 +195,45 @@ watch(() => props.ruleId, load);
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="check">
|
||||
<legend>Can this rule go stale?</legend>
|
||||
<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.
|
||||
</p>
|
||||
<label>
|
||||
How to check it is still true
|
||||
<textarea
|
||||
v-model="verifyWith"
|
||||
rows="2"
|
||||
placeholder="A command, a path, a query — something runnable beats prose."
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
What would end it
|
||||
<textarea
|
||||
v-model="expiresWhen"
|
||||
rows="2"
|
||||
placeholder="A state, not a date — “when the runner can be given a bash shell”."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-if="savedCheck" class="stamp">
|
||||
<span class="stamp-age" :class="{ unchecked: !verifiedAt }">{{ stampLabel }}</span>
|
||||
<span class="stamp-actions">
|
||||
<button type="button" :disabled="verifying" @click="verify(true)">Still true</button>
|
||||
<button type="button" :disabled="verifying" @click="verify(false)">No longer true</button>
|
||||
</span>
|
||||
</div>
|
||||
<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
|
||||
retire it.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<section v-if="relations.length" class="relations">
|
||||
<h3>Related rules</h3>
|
||||
<ul>
|
||||
@@ -185,6 +257,17 @@ 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>
|
||||
@@ -231,6 +314,35 @@ legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary);
|
||||
.relation-target { color: var(--fs-text-primary); }
|
||||
.relation-note { width: 100%; font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
||||
|
||||
/* A real base rule, not just descendants: the dangling-style check reads a
|
||||
class that only ever appears as an ancestor as a half-deleted rule, and it
|
||||
is right to — an element whose appearance comes only from its tag is one
|
||||
`fieldset {}` edit away from being unstyled. */
|
||||
.check { margin-bottom: 1rem; }
|
||||
.check .intro { margin-top: 0; margin-bottom: 0.75rem; }
|
||||
.check label { margin-bottom: 0.75rem; }
|
||||
.stamp {
|
||||
display: flex; align-items: center; gap: var(--fs-space-2);
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.stamp-age { font-size: 0.8rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
|
||||
/* Never-checked is INFORMATION, not an error: it is the ordinary starting
|
||||
state of every constraint anyone has just written. --fs-overdue (error red)
|
||||
is reserved for a broken promise like a missed due date; a verification age
|
||||
is not one, and colouring it that way would make a brand-new rule look
|
||||
broken. Secondary text, weighted normally. */
|
||||
.stamp-age.unchecked { color: var(--fs-text-tertiary); font-style: italic; }
|
||||
.stamp-actions { display: flex; gap: var(--fs-space-2); margin-left: auto; }
|
||||
.stamp-actions button {
|
||||
cursor: pointer; font: inherit; font-size: 0.78rem;
|
||||
background: var(--fs-surface-raised); color: var(--fs-text-primary);
|
||||
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-sm);
|
||||
padding: 0.2rem 0.55rem;
|
||||
}
|
||||
.stamp-actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
|
||||
.stamp-actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
|
||||
|
||||
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
||||
.trash:hover, .close:hover { opacity: 1; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
<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" | "tier"
|
||||
| "why" | "how_to_apply" | "verify_with" | "expires_when";
|
||||
|
||||
const FIELDS: Array<[TextField, string]> = [
|
||||
["title", "Title"],
|
||||
["statement", "Statement"],
|
||||
["when_to_apply", "When to apply"],
|
||||
["tier", "Tier"],
|
||||
["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>
|
||||
@@ -1,5 +1,16 @@
|
||||
<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<{
|
||||
@@ -17,7 +28,18 @@ const emit = defineEmits<{
|
||||
{{ r.title }}
|
||||
<!-- Only conditional is marked: always-on is the default and
|
||||
badging every row would say nothing. -->
|
||||
<span v-if="r.tier === 'conditional'" class="tier-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
|
||||
<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
|
||||
v-if="r.last_verified"
|
||||
class="rule-chip check-chip"
|
||||
:class="{ unchecked: r.last_verified === 'never' }"
|
||||
:title="r.last_verified === 'never'
|
||||
? '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">
|
||||
@@ -47,16 +69,13 @@ li:hover { background: var(--fs-surface-hover); }
|
||||
.meta { display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.35rem; font-size: 0.75em; }
|
||||
.trigger { flex: 1; min-width: 0; color: var(--fs-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.age { color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
||||
.tier-chip {
|
||||
margin-left: 0.4rem;
|
||||
font-family: var(--fs-font-body);
|
||||
font-style: normal;
|
||||
font-size: 0.62rem;
|
||||
color: var(--fs-text-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.05rem 0.4rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* Only the departures from .rule-chip (rules-shared.css) live here. */
|
||||
.check-chip { font-variant-numeric: tabular-nums; }
|
||||
/* No age-graded colour on purpose. The sweep is already ordered by urgency, so
|
||||
a red/amber ramp would restate the ordering AND require an invented "stale
|
||||
after N days" threshold — a magic number nobody could defend and the first
|
||||
thing to go out of date. Only "never" is marked, because it is categorically
|
||||
different from a date rather than a worse one. */
|
||||
.check-chip.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
|
||||
.new-rule { cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The staleness sweep: rules that assert a FACT, oldest verification first.
|
||||
*
|
||||
* Cross-cutting by nature — a rule that has gone false does not care which
|
||||
* rulebook it sits in — so this is its own pane rather than a filter on the
|
||||
* per-topic rule list. That list can only ever show one topic of one
|
||||
* rulebook, so filtering it would quietly under-report, which is the exact
|
||||
* failure this surface exists to catch.
|
||||
*/
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
async function verify(id: number, stillTrue: boolean) {
|
||||
busyId.value = id;
|
||||
try {
|
||||
await store.verifyRule(id, stillTrue);
|
||||
} finally {
|
||||
busyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(reload);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pane sweep">
|
||||
<header>
|
||||
<h2>Due for verification</h2>
|
||||
<p class="lede">
|
||||
Rules that assert a fact about something outside your control. Most rules 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>
|
||||
<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>
|
||||
|
||||
<!-- 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 || 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.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"
|
||||
: `${r.days_since_verified}d ago` }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="statement">{{ r.statement }}</p>
|
||||
|
||||
<dl class="check">
|
||||
<dt>Check</dt>
|
||||
<dd><code>{{ r.verify_with }}</code></dd>
|
||||
<template v-if="r.expires_when">
|
||||
<dt>Ends when</dt>
|
||||
<dd>{{ r.expires_when }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<div class="actions">
|
||||
<button :disabled="busyId === r.id" @click="verify(r.id, true)">Still true</button>
|
||||
<button :disabled="busyId === r.id" @click="verify(r.id, false)">No longer true</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p v-if="store.rulesDue.length" class="footnote">
|
||||
Record a result only after actually running the check. “No longer true” stores nothing
|
||||
on purpose — the rule is wrong rather than in a state worth recording, so it keeps its
|
||||
place here until you correct or retire it.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/rules-shared.css" />
|
||||
<style scoped>
|
||||
.sweep { display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
.lede {
|
||||
margin: 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); }
|
||||
.filter select {
|
||||
font: inherit; font-size: 0.82rem;
|
||||
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.2rem 0.4rem;
|
||||
}
|
||||
|
||||
.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 most overdue thing
|
||||
in the rulebook. 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); }
|
||||
|
||||
.statement { margin: 0.35rem 0 0; font-size: 0.88rem; color: var(--fs-text-secondary); }
|
||||
|
||||
.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; }
|
||||
.check code {
|
||||
font-family: var(--fs-font-mono);
|
||||
background: var(--fs-surface-code-inline);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.05rem 0.3rem;
|
||||
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>
|
||||
@@ -3,8 +3,8 @@ import { ref } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import type { Rulebook } from "@/api/rulebooks";
|
||||
|
||||
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null }>();
|
||||
const emit = defineEmits<{ select: [id: number] }>();
|
||||
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null; sweepActive: boolean }>();
|
||||
const emit = defineEmits<{ select: [id: number]; "select-sweep": [] }>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const isCreating = ref(false);
|
||||
@@ -34,6 +34,18 @@ async function submitNew() {
|
||||
<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
|
||||
every rule the operator owns. It lives here because this is where you
|
||||
come to look at rules, and a rule that has gone false belongs to no
|
||||
one rulebook. -->
|
||||
<button
|
||||
class="sweep-entry"
|
||||
:class="{ active: sweepActive }"
|
||||
@click="emit('select-sweep')"
|
||||
>
|
||||
Due for verification
|
||||
</button>
|
||||
|
||||
<div class="new-rulebook">
|
||||
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
|
||||
<form v-else @submit.prevent="submitNew">
|
||||
@@ -63,6 +75,15 @@ li:hover { background: var(--fs-surface-hover); }
|
||||
color: var(--fs-text-on-action);
|
||||
margin-left: auto;
|
||||
}
|
||||
.sweep-entry {
|
||||
display: block; width: 100%; text-align: left;
|
||||
margin-top: var(--fs-space-3);
|
||||
padding: 0.5rem; border-radius: 6px;
|
||||
background: none; border: 1px dashed var(--fs-border-color);
|
||||
color: var(--fs-text-secondary); font: inherit; cursor: pointer;
|
||||
}
|
||||
.sweep-entry:hover { background: var(--fs-surface-hover); }
|
||||
.sweep-entry.active { background: var(--fs-accent-soft); color: var(--fs-text-primary); }
|
||||
.new-rulebook { margin-top: 1rem; }
|
||||
.new-rulebook input {
|
||||
width: 100%; margin-bottom: 0.5rem;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 {
|
||||
@@ -9,17 +10,16 @@ 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,27 +31,6 @@ 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();
|
||||
|
||||
@@ -31,6 +31,8 @@ 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);
|
||||
@@ -42,7 +44,11 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
|
||||
async function updateNote(
|
||||
id: number,
|
||||
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type">>
|
||||
data: Partial<Pick<
|
||||
Note,
|
||||
"title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type"
|
||||
| "verify_with" | "expires_when"
|
||||
>>
|
||||
): Promise<Note> {
|
||||
try {
|
||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||
|
||||
@@ -9,6 +9,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
const topicsByRulebook = ref<Record<number, RulebookTopic[]>>({});
|
||||
const rulesByTopic = ref<Record<number, RuleHeader[]>>({});
|
||||
const currentRule = ref<Rule | null>(null);
|
||||
const rulesDue = ref<api.RuleVerificationRow[]>([]);
|
||||
// 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; tier?: api.RuleTier; neverOnly?: boolean }>({});
|
||||
const loading = ref(false);
|
||||
|
||||
async function fetchRulebooks() {
|
||||
@@ -111,6 +116,13 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
updated_at: rule.updated_at,
|
||||
when_to_apply: rule.when_to_apply || undefined,
|
||||
arose_from_id: rule.arose_from_id ?? undefined,
|
||||
// Mirrors services.rulebooks.last_verified_label: present ONLY when the
|
||||
// rule carries a check, and "never" rather than absent when it has one
|
||||
// nobody has run. Computed here so a row just written looks identical to
|
||||
// the same row re-fetched, instead of losing its chip until a reload.
|
||||
last_verified: rule.verify_with
|
||||
? (rule.verified_at ? rule.verified_at.slice(0, 10) : "never")
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,6 +160,41 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
await fetchRule(refreshRuleId);
|
||||
}
|
||||
|
||||
/** The staleness sweep: rules asserting a fact, oldest verification first. */
|
||||
async function fetchRulesDue(opts: {
|
||||
olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean;
|
||||
} = {}) {
|
||||
loading.value = true;
|
||||
lastSweepOpts.value = opts;
|
||||
try {
|
||||
const data = await api.listRulesDueForVerification(opts);
|
||||
rulesDue.value = data.rules;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a rule's check was RUN, and what it said.
|
||||
*
|
||||
* A pass re-sorts the row to the back of the sweep, so the list is re-read
|
||||
* rather than patched: the whole point of this surface is an ORDER, and a
|
||||
* locally-mutated row would sit in its old position claiming a new date.
|
||||
* A failure writes nothing server-side and the row keeps its place — also
|
||||
* correct, and also what a re-read shows.
|
||||
*/
|
||||
async function verifyRule(id: number, stillTrue: boolean) {
|
||||
const rule = await api.markRuleVerified(id, stillTrue);
|
||||
if (currentRule.value?.id === id) currentRule.value = rule;
|
||||
for (const tid of Object.keys(rulesByTopic.value)) {
|
||||
const list = rulesByTopic.value[Number(tid)];
|
||||
const idx = list.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) list[idx] = toHeader(rule);
|
||||
}
|
||||
if (rulesDue.value.length) await fetchRulesDue(lastSweepOpts.value);
|
||||
return rule;
|
||||
}
|
||||
|
||||
async function deleteRule(id: number) {
|
||||
await api.deleteRule(id);
|
||||
if (currentRule.value?.id === id) currentRule.value = null;
|
||||
@@ -157,10 +204,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
}
|
||||
|
||||
return {
|
||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading,
|
||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
|
||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
||||
createTopic, updateTopic, deleteTopic,
|
||||
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
||||
fetchRulesDue, verifyRule,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -55,6 +55,9 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
|
||||
async function updateTask(
|
||||
id: number,
|
||||
// IssueFields carries `kind`, which the PATCH route now reads. It has
|
||||
// always been SENT by the task editor; until #3129 the route dropped it
|
||||
// and the save reported success while changing nothing.
|
||||
data: Partial<
|
||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
|
||||
> & IssueFields
|
||||
|
||||
@@ -2,7 +2,16 @@ import type { System } from "@/api/systems";
|
||||
|
||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||
export type TaskKind = "work" | "plan" | "issue";
|
||||
/**
|
||||
* What KIND of work a task is, not how it is going.
|
||||
* work — ships a change (default)
|
||||
* issue — corrective; something was broken
|
||||
* spike — time-boxed, output is knowledge; it succeeds by producing an
|
||||
* answer and nothing ships at the end of it
|
||||
* plan — retired (plans are milestones); kept so historical plan-tasks
|
||||
* still render their kind
|
||||
*/
|
||||
export type TaskKind = "work" | "plan" | "issue" | "spike";
|
||||
export type NoteType = "note" | "process" | "snippet";
|
||||
|
||||
export interface Note {
|
||||
@@ -25,6 +34,15 @@ 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;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import KindBadge from "@/components/KindBadge.vue";
|
||||
import type { TaskKind } from "@/types/note";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
|
||||
interface TaskRow { id: number; title: string; status: string; priority: string }
|
||||
interface TaskRow { id: number; title: string; status: string; priority: string; task_kind?: TaskKind }
|
||||
interface MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
|
||||
interface ActiveProject {
|
||||
id: number; title: string; color: string | null; last_activity: string;
|
||||
@@ -99,6 +101,7 @@ onMounted(async () => {
|
||||
>
|
||||
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||
<span class="task-title">{{ t.title }}</span>
|
||||
<KindBadge :kind="t.task_kind" />
|
||||
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -114,6 +117,7 @@ onMounted(async () => {
|
||||
>
|
||||
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||
<span class="task-title">{{ t.title }}</span>
|
||||
<KindBadge :kind="t.task_kind" />
|
||||
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
@@ -1427,12 +1427,12 @@ textarea.input {
|
||||
|
||||
.spec-status.violated {
|
||||
background: var(--fs-priority-high-bg);
|
||||
color: var(--fs-priority-high);
|
||||
color: var(--fs-priority-high-fg);
|
||||
}
|
||||
|
||||
.spec-status.missing {
|
||||
background: var(--fs-priority-medium-bg);
|
||||
color: var(--fs-priority-medium);
|
||||
color: var(--fs-priority-medium-fg);
|
||||
}
|
||||
|
||||
.sheet {
|
||||
|
||||
@@ -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);
|
||||
color: var(--fs-accent-fg);
|
||||
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);
|
||||
color: var(--fs-accent-fg);
|
||||
}
|
||||
|
||||
.peek-linked-type {
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||
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";
|
||||
import {
|
||||
FileText,
|
||||
@@ -9,6 +14,7 @@ import {
|
||||
Workflow,
|
||||
Search,
|
||||
Share2,
|
||||
ShieldCheck,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
@@ -20,7 +26,7 @@ const router = useRouter();
|
||||
|
||||
interface KnowledgeItem {
|
||||
id: number;
|
||||
note_type: "note" | "task" | "process";
|
||||
note_type: "note" | "task" | "process" | "snippet";
|
||||
title: string;
|
||||
snippet: string;
|
||||
tags: string[];
|
||||
@@ -35,12 +41,44 @@ interface KnowledgeItem {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
due_date?: string;
|
||||
task_kind?: "work" | "plan";
|
||||
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<"" | "note" | "task" | "plan" | "process">("");
|
||||
const activeType = ref<Facet>("");
|
||||
const activeTag = ref("");
|
||||
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||
const searchQuery = ref("");
|
||||
@@ -66,9 +104,10 @@ const dupGroups = ref<DupGroup[]>([]);
|
||||
const dupSuggestion = ref("");
|
||||
const dupLoading = ref(false);
|
||||
const dupChecked = ref(false);
|
||||
// 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"));
|
||||
// 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"));
|
||||
|
||||
async function loadDuplicates() {
|
||||
dupLoading.value = true;
|
||||
@@ -92,8 +131,11 @@ watch(dupKind, () => { dupChecked.value = false; dupGroups.value = []; });
|
||||
|
||||
// ─── Type counts ──────────────────────────────────────────────────────────────
|
||||
|
||||
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 });
|
||||
// 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 });
|
||||
|
||||
async function fetchCounts() {
|
||||
try {
|
||||
@@ -230,6 +272,10 @@ 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 ────────────────────────────────────────────────────────────────
|
||||
@@ -270,9 +316,18 @@ 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}`);
|
||||
}
|
||||
@@ -376,14 +431,14 @@ onUnmounted(() => {
|
||||
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['process','Processes','process']] as [string,string,string][])"
|
||||
v-for="[val, label] in FACET_CHIPS"
|
||||
:key="val"
|
||||
class="filter-btn"
|
||||
:class="{ active: activeType === val }"
|
||||
@click="activeType = (val as '' | 'note' | 'task' | 'plan' | 'process')"
|
||||
@click="activeType = val"
|
||||
>
|
||||
<span class="filter-btn-label">{{ label }}</span>
|
||||
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
||||
<span v-if="(typeCounts[val] ?? 0) > 1" class="filter-count">{{ typeCounts[val] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -429,6 +484,15 @@ 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"
|
||||
@@ -439,6 +503,12 @@ 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
|
||||
@@ -476,6 +546,12 @@ 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">
|
||||
@@ -497,7 +573,14 @@ 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 /
|
||||
plan / process), and kind is the other axis. `plan` is passed
|
||||
as null because the badge to the left already says it — two
|
||||
chips reading "Plan" would look like two facts. -->
|
||||
<KindBadge :kind="item.task_kind === 'plan' ? null : item.task_kind" />
|
||||
|
||||
<div class="k-card-body">
|
||||
<div class="k-card-title">{{ item.title }}</div>
|
||||
@@ -505,14 +588,12 @@ onUnmounted(() => {
|
||||
<!-- Task specifics -->
|
||||
<div v-if="item.note_type === 'task'" class="k-card-task">
|
||||
<div class="task-badges">
|
||||
<span class="status-badge" :class="`status--${item.status}`">
|
||||
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
|
||||
</span>
|
||||
<span
|
||||
<StatusBadge v-if="item.status" :status="item.status as TaskStatus" compact />
|
||||
<PriorityBadge
|
||||
v-if="item.priority && item.priority !== 'none'"
|
||||
class="priority-badge"
|
||||
:class="`priority--${item.priority}`"
|
||||
>{{ item.priority }}</span>
|
||||
:priority="item.priority as TaskPriority"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-if="item.due_date"
|
||||
@@ -544,6 +625,7 @@ onUnmounted(() => {
|
||||
<span v-if="contentFetching" class="sentinel-loading">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Graph panel -->
|
||||
@@ -745,7 +827,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.filter-btn.active .filter-count {
|
||||
background: color-mix(in srgb, var(--fs-accent) 20%, transparent);
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
}
|
||||
.filter-tag { font-size: 0.78rem; }
|
||||
|
||||
@@ -873,6 +955,14 @@ 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 {
|
||||
@@ -918,7 +1008,7 @@ onUnmounted(() => {
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--fs-text-secondary) 15%, transparent);
|
||||
color: var(--fs-text-secondary);
|
||||
color: var(--fs-text-secondary-fg);
|
||||
}
|
||||
|
||||
/* ── Task card ──────────────────────────────────────────── */
|
||||
@@ -932,26 +1022,7 @@ onUnmounted(() => {
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.status-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status--todo { background: var(--fs-status-todo-bg); color: var(--fs-status-todo); }
|
||||
.status--in_progress { background: var(--fs-status-in-progress-bg); color: var(--fs-status-in-progress); }
|
||||
.status--done { background: var(--fs-status-done-bg); color: var(--fs-status-done); }
|
||||
.status--cancelled { background: var(--fs-status-todo-bg); color: var(--fs-status-todo); text-decoration: line-through; }
|
||||
|
||||
.priority-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.priority--low { background: var(--fs-priority-low-bg); color: var(--fs-priority-low); }
|
||||
.priority--normal { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); }
|
||||
.priority--high { background: var(--fs-priority-high-bg); color: var(--fs-priority-high); }
|
||||
|
||||
.task-due {
|
||||
font-size: 0.78rem;
|
||||
|
||||
@@ -34,6 +34,14 @@ 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);
|
||||
@@ -198,6 +206,41 @@ 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 =
|
||||
@@ -206,7 +249,9 @@ function markDirty() {
|
||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||
projectId.value !== savedProjectId ||
|
||||
milestoneId.value !== savedMilestoneId ||
|
||||
noteType.value !== savedNoteType;
|
||||
noteType.value !== savedNoteType ||
|
||||
verifyWith.value !== savedVerifyWith ||
|
||||
expiresWhen.value !== savedExpiresWhen;
|
||||
}
|
||||
|
||||
function onBodyUpdate(newVal: string) {
|
||||
@@ -224,12 +269,10 @@ 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";
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedNoteType = noteType.value;
|
||||
verifyWith.value = store.currentNote.verify_with || "";
|
||||
expiresWhen.value = store.currentNote.expires_when || "";
|
||||
verifiedAt.value = store.currentNote.verified_at ?? null;
|
||||
snapshot();
|
||||
}
|
||||
} else {
|
||||
// New note: read type from query param
|
||||
@@ -260,31 +303,11 @@ async function save() {
|
||||
const finalBody = body.value;
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
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;
|
||||
await store.updateNote(noteId.value!, { ...payload(), body: finalBody });
|
||||
snapshot();
|
||||
toast.show("Note saved");
|
||||
} else {
|
||||
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,
|
||||
});
|
||||
const note = await store.createNote({ ...payload(), body: finalBody });
|
||||
dirty.value = false;
|
||||
toast.show("Note created");
|
||||
router.push(`/notes/${note.id}`);
|
||||
@@ -321,18 +344,8 @@ async function doAutoSave() {
|
||||
saving.value = true;
|
||||
const finalBody = body.value;
|
||||
try {
|
||||
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;
|
||||
await store.updateNote(noteId.value!, { ...payload(), body: finalBody });
|
||||
snapshot();
|
||||
toast.show("Auto-saved");
|
||||
} catch {
|
||||
// Silent
|
||||
@@ -496,6 +509,41 @@ 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">
|
||||
@@ -678,7 +726,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sb-select, .sb-input {
|
||||
.sb-select, .sb-input, .sb-textarea {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
@@ -690,9 +738,27 @@ onUnmounted(() => assist.clearSelection());
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.sb-select:focus, .sb-input:focus {
|
||||
.sb-select:focus, .sb-input:focus, .sb-textarea: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; }
|
||||
|
||||
@@ -416,7 +416,7 @@ async function convertToTask() {
|
||||
}
|
||||
.badge-note {
|
||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
|
||||
}
|
||||
.badge-task {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
|
||||
import ProjectStatusBadge from "@/components/ProjectStatusBadge.vue";
|
||||
import { emptyChoices, type InceptionChoices } from "@/api/inception";
|
||||
import InceptionCard from "@/components/InceptionCard.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
@@ -109,13 +110,6 @@ async function createProject() {
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: Project["status"]): string {
|
||||
if (status === "active") return "Active";
|
||||
if (status === "paused") return "Paused";
|
||||
if (status === "completed") return "Completed";
|
||||
if (status === "archived") return "Archived";
|
||||
return status;
|
||||
}
|
||||
|
||||
function truncate(text: string | null, max = 120): string {
|
||||
if (!text) return "";
|
||||
@@ -210,9 +204,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
>
|
||||
<div class="card-header">
|
||||
<span class="project-title">{{ project.title }}</span>
|
||||
<span
|
||||
:class="['status-badge', `status-${project.status}`]"
|
||||
>{{ statusLabel(project.status) }}</span>
|
||||
<ProjectStatusBadge :status="project.status" />
|
||||
</div>
|
||||
<p v-if="project.goal" class="project-goal">
|
||||
<span class="field-label">Goal:</span> {{ truncate(project.goal) }}
|
||||
@@ -430,28 +422,6 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-active {
|
||||
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
|
||||
color: var(--fs-success);
|
||||
}
|
||||
.status-completed {
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.status-archived {
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.project-goal {
|
||||
font-size: 0.875rem;
|
||||
|
||||
@@ -8,6 +8,9 @@ import { useTasksStore } from "@/stores/tasks";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import ShareDialog from "@/components/ShareDialog.vue";
|
||||
import KindBadge from "@/components/KindBadge.vue";
|
||||
import ProjectStatusBadge from "@/components/ProjectStatusBadge.vue";
|
||||
import type { TaskKind } from "@/types/note";
|
||||
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
|
||||
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
||||
import SystemsSection from "@/components/SystemsSection.vue";
|
||||
@@ -74,6 +77,7 @@ interface NoteItem {
|
||||
due_date?: string | null;
|
||||
updated_at: string;
|
||||
milestone_id?: number | null;
|
||||
task_kind?: TaskKind;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
@@ -693,9 +697,7 @@ async function confirmDelete() {
|
||||
<div class="project-header">
|
||||
<div class="title-row">
|
||||
<input v-model="editTitle" type="text" class="project-title-input" placeholder="Project title" />
|
||||
<span :class="['status-badge', `status-${project.status}`]">
|
||||
{{ project.status.charAt(0).toUpperCase() + project.status.slice(1) }}
|
||||
</span>
|
||||
<ProjectStatusBadge :status="project.status" />
|
||||
</div>
|
||||
<p v-if="project.goal" class="project-goal">{{ project.goal }}</p>
|
||||
<p v-if="project.summary?.last_activity" class="project-activity">
|
||||
@@ -1046,6 +1048,7 @@ async function confirmDelete() {
|
||||
:class="['task-card', `pri-${task.priority || 'none'}`]"
|
||||
>
|
||||
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<div class="task-card-footer">
|
||||
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
|
||||
@@ -1077,6 +1080,7 @@ async function confirmDelete() {
|
||||
:class="['task-card', `pri-${task.priority || 'none'}`]"
|
||||
>
|
||||
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<div class="task-card-footer">
|
||||
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
|
||||
@@ -1108,6 +1112,7 @@ async function confirmDelete() {
|
||||
class="task-card task-card-done"
|
||||
>
|
||||
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<div v-if="task.due_date" class="task-meta">
|
||||
<span class="due-date">{{ task.due_date }}</span>
|
||||
</div>
|
||||
@@ -1234,19 +1239,6 @@ async function confirmDelete() {
|
||||
.project-title-input:focus { border-bottom-color: var(--fs-accent); }
|
||||
.project-title-input::placeholder { color: var(--fs-text-tertiary); font-weight: 400; }
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-active { background: color-mix(in srgb, var(--fs-success) 14%, transparent); color: var(--fs-success); border: 1px solid color-mix(in srgb, var(--fs-success) 30%, transparent); }
|
||||
.status-paused { background: color-mix(in srgb, var(--fs-warning) 14%, transparent); color: var(--fs-warning); border: 1px solid color-mix(in srgb, var(--fs-warning) 30%, transparent); }
|
||||
.status-completed { background: color-mix(in srgb, var(--fs-accent) 14%, transparent); color: var(--fs-accent); border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent); }
|
||||
.status-archived { background: color-mix(in srgb, var(--fs-text-tertiary) 14%, transparent); color: var(--fs-text-tertiary); border: 1px solid color-mix(in srgb, var(--fs-text-tertiary) 30%, transparent); }
|
||||
|
||||
.project-goal {
|
||||
font-size: 1rem;
|
||||
@@ -1295,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); 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); }
|
||||
.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); }
|
||||
|
||||
/* ── Pattern-library coverage card ───────────────────────────── */
|
||||
.coverage-card {
|
||||
@@ -1479,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);
|
||||
color: var(--fs-accent-fg);
|
||||
}
|
||||
|
||||
/* ── Tasks view ──────────────────────────────────────────────── */
|
||||
@@ -1715,7 +1707,7 @@ async function confirmDelete() {
|
||||
border-radius: 3px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.col-add-btn:hover { color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
|
||||
.col-add-btn:hover { color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
|
||||
|
||||
.kanban-cards { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import RulebookListPane from "@/components/rules/RulebookListPane.vue";
|
||||
import RulebookDetailPane from "@/components/rules/RulebookDetailPane.vue";
|
||||
import RuleListPane from "@/components/rules/RuleListPane.vue";
|
||||
import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue";
|
||||
import RuleSweepPane from "@/components/rules/RuleSweepPane.vue";
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const route = useRoute();
|
||||
@@ -15,6 +16,7 @@ const selectedRulebookId = ref<number | null>(null);
|
||||
const selectedTopicId = ref<number | null>(null);
|
||||
const editingRuleId = ref<number | null>(null);
|
||||
const creatingRuleForTopic = ref<number | null>(null);
|
||||
const sweepActive = ref(false);
|
||||
|
||||
function syncFromRoute() {
|
||||
const rb = route.query.rb ? Number(route.query.rb) : null;
|
||||
@@ -23,9 +25,20 @@ function syncFromRoute() {
|
||||
selectedRulebookId.value = rb;
|
||||
selectedTopicId.value = topic;
|
||||
editingRuleId.value = rule;
|
||||
sweepActive.value = route.query.view === "due";
|
||||
}
|
||||
|
||||
function selectSweep() {
|
||||
sweepActive.value = true;
|
||||
// Keeps ?rule=… so the editor survives the mode switch, and drops the
|
||||
// rulebook/topic selection the sweep does not use.
|
||||
const { rb, topic, ...rest } = route.query;
|
||||
void rb; void topic;
|
||||
router.replace({ query: { ...rest, view: "due" } });
|
||||
}
|
||||
|
||||
function selectRulebook(id: number) {
|
||||
sweepActive.value = false;
|
||||
selectedRulebookId.value = id;
|
||||
selectedTopicId.value = null;
|
||||
router.replace({ query: { rb: String(id) } });
|
||||
@@ -70,10 +83,13 @@ watch(() => route.query, syncFromRoute);
|
||||
<RulebookListPane
|
||||
:rulebooks="store.rulebooks"
|
||||
:selected-id="selectedRulebookId"
|
||||
:sweep-active="sweepActive"
|
||||
@select="selectRulebook"
|
||||
@select-sweep="selectSweep"
|
||||
/>
|
||||
<RuleSweepPane v-if="sweepActive" class="sweep-span" @open-rule="openRule" />
|
||||
<RulebookDetailPane
|
||||
v-if="selectedRulebookId !== null"
|
||||
v-else-if="selectedRulebookId !== null"
|
||||
:rulebook-id="selectedRulebookId"
|
||||
:topics="store.topicsByRulebook[selectedRulebookId] || []"
|
||||
:selected-topic-id="selectedTopicId"
|
||||
@@ -83,13 +99,13 @@ watch(() => route.query, syncFromRoute);
|
||||
<p>Select a rulebook to view its topics.</p>
|
||||
</div>
|
||||
<RuleListPane
|
||||
v-if="selectedTopicId !== null"
|
||||
v-if="!sweepActive && selectedTopicId !== null"
|
||||
:topic-id="selectedTopicId"
|
||||
:rules="store.rulesByTopic[selectedTopicId] || []"
|
||||
@open-rule="openRule"
|
||||
@create-rule="startCreatingRule"
|
||||
/>
|
||||
<div v-else class="pane empty">
|
||||
<div v-else-if="!sweepActive" class="pane empty">
|
||||
<p>Select a topic to view its rules.</p>
|
||||
</div>
|
||||
<RuleEditorSlideOver
|
||||
@@ -109,6 +125,9 @@ watch(() => route.query, syncFromRoute);
|
||||
gap: 1px;
|
||||
background: var(--fs-border-color);
|
||||
}
|
||||
/* The sweep is cross-cutting, so it takes the width the rulebook + topic
|
||||
panes would have used rather than being squeezed into one column. */
|
||||
.sweep-span { grid-column: 2 / -1; }
|
||||
.pane.empty {
|
||||
background: var(--fs-surface-hover);
|
||||
padding: 1rem;
|
||||
|
||||
@@ -9,6 +9,7 @@ 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();
|
||||
@@ -85,6 +86,7 @@ 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
|
||||
@@ -147,12 +149,17 @@ 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 {
|
||||
@@ -165,6 +172,10 @@ 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),
|
||||
@@ -187,7 +198,47 @@ const changingPassword = ref(false);
|
||||
const invalidatingSessions = ref(false);
|
||||
const exporting = ref(false);
|
||||
const restoring = ref(false);
|
||||
const appVersion = ref('dev');
|
||||
// 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 restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
||||
@@ -201,6 +252,7 @@ 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(); }
|
||||
}
|
||||
@@ -554,10 +606,6 @@ 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 ?? "";
|
||||
|
||||
@@ -573,6 +621,9 @@ 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;
|
||||
}
|
||||
@@ -727,7 +778,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);
|
||||
const res = await fetch(url, { signal: bulkDeadline() });
|
||||
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}`);
|
||||
@@ -752,7 +803,7 @@ const exportingNotes = ref(false);
|
||||
async function exportNotes(format: "markdown" | "json") {
|
||||
exportingNotes.value = true;
|
||||
try {
|
||||
const res = await fetch(`/api/export?format=${format}`);
|
||||
const res = await fetch(`/api/export?format=${format}`, { signal: bulkDeadline() });
|
||||
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
const ext = format === "json" ? "json" : "zip";
|
||||
@@ -981,6 +1032,7 @@ 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}` }));
|
||||
@@ -1417,6 +1469,29 @@ async function deleteUser(userId: number) {
|
||||
location, not by resemblance.
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="kb-rulehint-threshold">Standing-rule confidence threshold (0–1)</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
|
||||
@@ -2109,6 +2184,48 @@ 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…</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">
|
||||
@@ -2732,7 +2849,7 @@ async function deleteUser(userId: number) {
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.sidebar-item.active {
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
background: color-mix(in srgb, var(--fs-accent) 8%, transparent);
|
||||
border-left-color: var(--fs-accent);
|
||||
font-weight: 500;
|
||||
@@ -2768,6 +2885,45 @@ 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;
|
||||
@@ -3099,7 +3255,7 @@ async function deleteUser(userId: number) {
|
||||
border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
.role-admin {
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
}
|
||||
.role-user {
|
||||
@@ -3179,9 +3335,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); 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); }
|
||||
.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); }
|
||||
.method-tag {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem; font-weight: 500; font-family: monospace;
|
||||
@@ -3346,8 +3502,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); }
|
||||
.role-member { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary); }
|
||||
.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); }
|
||||
|
||||
.members-empty {
|
||||
color: var(--fs-text-tertiary);
|
||||
@@ -3528,7 +3684,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);
|
||||
color: var(--fs-accent-fg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
.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); }
|
||||
.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); }
|
||||
|
||||
.empty-msg {
|
||||
margin: 0;
|
||||
|
||||
@@ -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);
|
||||
color: var(--fs-accent-fg);
|
||||
padding: 0.08rem 0.35rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
word-break: break-all;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type SnippetListItem,
|
||||
} from "@/api/snippets";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import UsageBadge from "@/components/UsageBadge.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToastStore();
|
||||
@@ -198,23 +199,6 @@ 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. */
|
||||
@@ -254,22 +238,13 @@ function driftTitle(s: SnippetListItem): string {
|
||||
return v.detail ? `${when}: ${what}. ${v.detail}` : `${when}: ${what}.`;
|
||||
}
|
||||
|
||||
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}`
|
||||
);
|
||||
}
|
||||
/** 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.";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -456,14 +431,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
<span v-if="driftBadge(s)" class="drift-tag" :title="driftTitle(s)">
|
||||
{{ driftBadge(s) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="usageBadge(s)"
|
||||
class="usage-tag"
|
||||
:class="{ 'usage-dead': isDeadWeight(s) }"
|
||||
:title="usageTitle(s)"
|
||||
>
|
||||
{{ usageBadge(s) }}
|
||||
</span>
|
||||
<UsageBadge :usage="s.usage" :dead-weight-advice="SNIPPET_DEAD_WEIGHT" />
|
||||
<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>
|
||||
@@ -710,7 +678,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
}
|
||||
|
||||
.snippet-when {
|
||||
@@ -739,7 +707,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--fs-text-tertiary-fg);
|
||||
}
|
||||
|
||||
.dup-action {
|
||||
@@ -754,24 +722,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
|
||||
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);
|
||||
color: var(--fs-error-fg);
|
||||
}
|
||||
|
||||
/* Header + select-mode */
|
||||
|
||||
@@ -578,6 +578,7 @@ useEditorGuards(dirty, save);
|
||||
<select v-model="kind" @change="markDirty" class="sb-select">
|
||||
<option value="work">Work</option>
|
||||
<option value="issue">Issue</option>
|
||||
<option value="spike">Spike</option>
|
||||
<!-- 'plan' is retired (plans are milestones via start_planning);
|
||||
offered only so legacy plan-tasks display their kind. -->
|
||||
<option v-if="kind === 'plan'" value="plan">Plan (legacy)</option>
|
||||
|
||||
@@ -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 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",
|
||||
"version": "2026.09.09.0408",
|
||||
"author": {
|
||||
"name": "Bryan Van Deusen"
|
||||
},
|
||||
|
||||
+7
-2
@@ -78,8 +78,13 @@ On install you'll be asked for:
|
||||
|
||||
## Notes
|
||||
|
||||
- Set a `version` bump in `.claude-plugin/plugin.json` per release so clients
|
||||
pick up changes.
|
||||
- **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.
|
||||
- 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.
|
||||
|
||||
@@ -33,6 +33,15 @@
|
||||
"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": [
|
||||
|
||||
@@ -175,6 +175,16 @@ 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 the SessionStart hook stored, handed back so the server
|
||||
# can say whether those rules moved since (milestone 323). Nothing stored
|
||||
# means nothing sent, which the server reads as silence rather than as a
|
||||
# mismatch — an install that never reached /api/plugin/context must not
|
||||
# start claiming its rules changed.
|
||||
etag_q=""
|
||||
if [ -f "$state_dir/${safe_sid}.rules_etag" ]; then
|
||||
held=$(jq -sRr '@uri' < "$state_dir/${safe_sid}.rules_etag" 2>/dev/null) || held=""
|
||||
[ -n "$held" ] && etag_q="&rules_etag=${held}"
|
||||
fi
|
||||
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
|
||||
@@ -184,7 +194,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}" 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}${etag_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.
|
||||
|
||||
@@ -55,6 +55,53 @@ 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; }
|
||||
@@ -109,6 +156,28 @@ 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)
|
||||
# Stash the rules marker for the write-path hook (milestone 323). THIS is
|
||||
# where it has to be captured: the model receives one from
|
||||
# list_always_on_rules too, but a hook cannot see an MCP tool's result. Stored
|
||||
# under the same state dir the prior-art hook already uses, keyed by session,
|
||||
# so "changed since" means since THIS session loaded its rules.
|
||||
#
|
||||
# Written on `compact` as well as `startup`, and that is correct rather than
|
||||
# convenient: a compact tells the session to re-pull its rules, so the marker
|
||||
# should describe the set it is about to hold. It is also why this cannot
|
||||
# cover the compaction case — see the table in services/plugin_context.py.
|
||||
if [ -n "$body" ]; then
|
||||
etag=$(printf '%s' "$body" | jq -r '.rules_etag // empty' 2>/dev/null) || etag=""
|
||||
if [ -n "$etag" ]; then
|
||||
sid=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || sid=""
|
||||
safe_sid=$(printf '%s' "${sid:-nosession}" | tr -c 'A-Za-z0-9._-' '_')
|
||||
etag_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
||||
# Best-effort throughout: a marker that cannot be stored costs a hint,
|
||||
# never the session.
|
||||
mkdir -p "$etag_dir" 2>/dev/null \
|
||||
&& printf '%s' "$etag" > "$etag_dir/${safe_sid}.rules_etag" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
[ -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; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
|
||||
|
||||
@@ -21,6 +21,16 @@ for the operator's work, and as your own working memory across sessions.
|
||||
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.
|
||||
- **What you loaded is not all of the rules.** Only the always-on tier arrives
|
||||
that way; conditional rules are RETRIEVED, and one you were never handed
|
||||
binds exactly as hard. So before a consequential act, `search` for a rule
|
||||
about it (`content_type="rule"`) rather than concluding from an empty
|
||||
loaded set 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.
|
||||
- **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
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/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 always-on preload instead.
|
||||
#
|
||||
# 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"
|
||||
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
|
||||
|
||||
# `|| 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 >> "$rulefile" || true
|
||||
fi
|
||||
|
||||
jq -cn --arg ctx "$context" '{
|
||||
hookSpecificOutput: {
|
||||
hookEventName: "PreToolUse",
|
||||
additionalContext: $ctx
|
||||
}
|
||||
}' 2>/dev/null || true
|
||||
exit 0
|
||||
@@ -56,10 +56,31 @@ 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. **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.
|
||||
2. **Standing rules are binding — and the ones you were handed are not all of
|
||||
them.** Load the resident set 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.
|
||||
|
||||
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.
|
||||
|
||||
3. **Update over duplicate.** When recording, prefer updating an existing
|
||||
note/rule/task over creating a new one. Search first; revise what's there.
|
||||
@@ -96,7 +117,25 @@ Two constraints on *how* that's achieved:
|
||||
not restraint. Only a record genuinely about no particular area goes
|
||||
untagged.
|
||||
|
||||
8. **State updates in place; chronicles don't.** A dev-log records what
|
||||
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
|
||||
*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,
|
||||
@@ -106,6 +145,48 @@ 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
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/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"
|
||||
@@ -90,6 +90,59 @@ def style_source(path: pathlib.Path) -> str:
|
||||
return CSS_COMMENT.sub(" ", css)
|
||||
|
||||
|
||||
# A rule that paints text with a colour token AND its own -bg tint of the same
|
||||
# token. The pair looks harmonious and is close to illegible: a 12% tint of a
|
||||
# hue sits near the surface, so the hue as text on it lands around 2:1 against
|
||||
# an AA floor of 4.5. Measured across the whole Scribe ladder in 2026-08:
|
||||
# every one of the six pairs failed on the dark palette, worst 1.60:1.
|
||||
#
|
||||
# The fix is always the same and always available — the token's `-fg` sibling,
|
||||
# which is the hue mixed toward --fs-text-primary far enough to clear AA. So
|
||||
# this FAILS rather than reports: unlike a raw literal, there is nothing to
|
||||
# weigh up.
|
||||
SAME_TOKEN_PAIR = re.compile(
|
||||
r"color\s*:\s*var\(\s*(--fs-[\w-]+?)\s*\)" # color: var(--fs-X)
|
||||
r"|background(?:-color)?\s*:\s*var\(\s*(--fs-[\w-]+?)-bg\s*\)"
|
||||
)
|
||||
|
||||
|
||||
def same_hue_text_on_tint(css: str) -> tuple[list[str], list[str]]:
|
||||
"""Tokens used as TEXT on a tint of themselves, within one rule block.
|
||||
|
||||
TWO SPELLINGS of the same background, because the first version of this
|
||||
check only knew the first and missed four live instances:
|
||||
|
||||
background: var(--fs-X-bg) the token
|
||||
background: color-mix(in srgb, var(--fs-X) N%, transparent) inline
|
||||
|
||||
The inline form is what the project-status pills used, and it is the more
|
||||
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.
|
||||
"""
|
||||
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))
|
||||
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*\)[^;]*?\)",
|
||||
body,
|
||||
))
|
||||
token_form.extend(sorted(fg & bg_tok))
|
||||
inline_form.extend(sorted(fg & (bg_inl - bg_tok)))
|
||||
return token_form, inline_form
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--sheet", default="frontend/src/assets/theme.css")
|
||||
@@ -117,6 +170,8 @@ def main() -> int:
|
||||
)
|
||||
|
||||
unresolved: list[tuple[pathlib.Path, str]] = []
|
||||
same_hue_hits: list[tuple[pathlib.Path, str]] = []
|
||||
inline_tint_hits: list[tuple[pathlib.Path, str]] = []
|
||||
superseded_hits: list[tuple[pathlib.Path, str, str]] = []
|
||||
literal_count = 0
|
||||
|
||||
@@ -140,6 +195,12 @@ def main() -> int:
|
||||
|
||||
literal_count += len(HEX_LITERAL.findall(css))
|
||||
|
||||
tok_hits, inl_hits = same_hue_text_on_tint(css)
|
||||
for tok in tok_hits:
|
||||
same_hue_hits.append((path, tok))
|
||||
for tok in inl_hits:
|
||||
inline_tint_hits.append((path, tok))
|
||||
|
||||
if unresolved:
|
||||
print(f"FAIL — {len(unresolved)} unresolvable var() reference(s).")
|
||||
print(" These render as the fallback if given one, or as nothing at all.")
|
||||
@@ -162,12 +223,37 @@ def main() -> int:
|
||||
print(f" {path}: {literal} -> {token}")
|
||||
print()
|
||||
|
||||
if same_hue_hits:
|
||||
print(f"FAIL — {len(same_hue_hits)} rule(s) paint text with a token on a "
|
||||
f"tint of that same token.")
|
||||
print(" A 12% tint sits near the surface, so the hue as text on it lands "
|
||||
"around 2:1 against AA's 4.5.")
|
||||
print(" Use the token's -fg sibling, which is mixed toward "
|
||||
"--fs-text-primary until it clears the floor.\n")
|
||||
for path, tok in same_hue_hits:
|
||||
print(f" {path}: color: var({tok}) on var({tok}-bg) -> var({tok}-fg)")
|
||||
print()
|
||||
else:
|
||||
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")
|
||||
|
||||
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 else 0
|
||||
return 1 if (unresolved or same_hue_hits or inline_tint_hits) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+278
-45
@@ -13,9 +13,20 @@ 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.
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
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
|
||||
@@ -31,8 +42,15 @@ itself loudly, because a check that quietly no-ops is the failure mode this
|
||||
whole file exists to prevent.
|
||||
|
||||
Usage:
|
||||
python3 scripts/check_plugin.py # all checks
|
||||
python3 scripts/check_plugin.py --no-version # skip the bump check
|
||||
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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -43,16 +61,90 @@ 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"
|
||||
|
||||
# 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")
|
||||
# ── 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
|
||||
)
|
||||
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
@@ -143,8 +235,6 @@ def check_patterns() -> None:
|
||||
ok(f"{rel}: no known-bad patterns")
|
||||
|
||||
|
||||
# --- the version bump ------------------------------------------------------
|
||||
|
||||
# --- shellcheck ------------------------------------------------------------
|
||||
|
||||
def check_shellcheck() -> None:
|
||||
@@ -215,6 +305,16 @@ 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
|
||||
@@ -391,80 +491,213 @@ def _git(*args: str) -> tuple[int, str]:
|
||||
return proc.returncode, (proc.stdout or proc.stderr).strip()
|
||||
|
||||
|
||||
def manifest_version(ref: str | None = None) -> str | None:
|
||||
"""The manifest version at `ref`, or in the working tree when ref is None."""
|
||||
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.
|
||||
"""
|
||||
if ref is None:
|
||||
try:
|
||||
return json.loads(MANIFEST.read_text()).get("version")
|
||||
except Exception:
|
||||
return MANIFEST.read_text()
|
||||
except OSError:
|
||||
return None
|
||||
rel = MANIFEST.relative_to(ROOT).as_posix()
|
||||
code, out = _git("show", f"{ref}:{rel}")
|
||||
if code != 0:
|
||||
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(out).get("version")
|
||||
return json.loads(text).get("version")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def check_version_bump(base: str = "origin/main") -> None:
|
||||
"""If shipped plugin content differs from `base`, the version must too.
|
||||
# Distinct from None, which is a legitimate "this manifest does not exist".
|
||||
_UNREADABLE = object()
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
def check_version_is_minted(base: str = "origin/main") -> None:
|
||||
"""THE control (#3127 checklist 4), replacing "somebody remembers".
|
||||
|
||||
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.
|
||||
"""
|
||||
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 version-bump check could not run. "
|
||||
f"cannot resolve {base}, so the minted-version 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
|
||||
|
||||
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)
|
||||
here = manifest_version()
|
||||
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 here == there:
|
||||
files = "\n ".join(changed.splitlines())
|
||||
|
||||
if changed and here == there:
|
||||
files = "\n ".join(paths)
|
||||
fail(
|
||||
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"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" 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"plugin content changed and version moved {there} -> {here}")
|
||||
ok(f"nothing version-relevant changed against {base} — no mint required")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--no-version", action="store_true",
|
||||
help="skip the manifest version-bump check")
|
||||
help="skip the minted-version check; for `main`, where "
|
||||
"it would be measured against itself")
|
||||
parser.add_argument("--base", default="origin/main",
|
||||
help="branch the version bump is measured against")
|
||||
help="branch the version is measured against")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not HOOKS_DIR.is_dir():
|
||||
@@ -478,7 +711,7 @@ def main() -> int:
|
||||
check_local_prior_art_needs_no_instance()
|
||||
check_session_context_reports_its_version()
|
||||
if not args.no_version:
|
||||
check_version_bump(args.base)
|
||||
check_version_is_minted(args.base)
|
||||
|
||||
print()
|
||||
if failures:
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/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())
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/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
|
||||
@@ -30,6 +30,46 @@ 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 or
|
||||
# list_always_on_rules. 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. The tier split is
|
||||
# therefore load-bearing on ANY install (rule 115): a delivered rule costs
|
||||
# tokens in every session forever, so a rulebook that only delivers cannot
|
||||
# grow past what one session can hold, and every rule worth keeping has to
|
||||
# become resident to bind at all. Retrieval is what lets it keep growing —
|
||||
# and retrieval fires only if something asks, which nothing told a session
|
||||
# to do. A tool-choice reflex asks least of all (#3476, #161).
|
||||
# - This states the PULL for conditional rules, exactly as the surrounding
|
||||
# line states it for always-on ones. 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
|
||||
@@ -48,13 +88,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 are binding — list_always_on_rules() at session start.
|
||||
- HOW: rules bind. list_always_on_rules() at start; before a consequential
|
||||
act, search(content_type="rule") — the resident set is not all of them.
|
||||
- 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. Processes are saved procedures (follow
|
||||
verbatim). Deletes are trash-recoverable.
|
||||
consumer map is rows, never prose.
|
||||
|
||||
A task is a note with status (*_note vs *_task tools).
|
||||
Creates are duplicate-gated: a near-match BLOCKS and returns the existing
|
||||
@@ -120,6 +160,13 @@ _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
|
||||
|
||||
@@ -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),
|
||||
**rulebooks_svc.rules_payload(applicable, user_id=uid, source="get_milestone"),
|
||||
}
|
||||
|
||||
|
||||
@@ -137,11 +137,23 @@ 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_batch_id": batch,
|
||||
"message": f"Milestone {milestone_id} + its tasks moved to trash. Restore with restore('{batch}')."}
|
||||
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}')."}
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
|
||||
@@ -103,10 +103,23 @@ 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.
|
||||
|
||||
WHAT ELSE COULD HOLD THIS? A note is the right home when the answer is
|
||||
"nothing": it records what you know, nobody owes anything on it, and
|
||||
nothing enforces it. Otherwise —
|
||||
- someone has to DO something -> create_task. A note titled "we should…"
|
||||
is a task nobody will ever see again.
|
||||
- future sessions must OBEY it -> create_rule. The test is whether
|
||||
ignoring it would be a mistake, not merely uninformed.
|
||||
- reusable code with a place in a repo -> create_snippet. The location is
|
||||
what lets it be found from the file someone is about to edit.
|
||||
- a procedure followed start to finish -> create_process.
|
||||
|
||||
Args:
|
||||
title: Note title (required).
|
||||
body: Markdown content. Supports [[wikilinks]] to other notes by title.
|
||||
@@ -123,6 +136,33 @@ 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
|
||||
@@ -150,6 +190,8 @@ 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)
|
||||
@@ -174,6 +216,9 @@ 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.
|
||||
|
||||
@@ -188,6 +233,27 @@ 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 = {}
|
||||
@@ -199,7 +265,13 @@ async def update_note(
|
||||
fields["tags"] = tags
|
||||
if project_id:
|
||||
fields["project_id"] = project_id
|
||||
note = await notes_svc.update_note(uid, note_id, **fields)
|
||||
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
|
||||
)
|
||||
if note is None:
|
||||
raise ValueError(f"note {note_id} not found")
|
||||
if system_ids is not None:
|
||||
@@ -252,11 +324,113 @@ 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_batch_id": batch,
|
||||
"message": f"Note {note_id} moved to trash. Restore with restore('{batch}')."}
|
||||
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
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
@@ -267,5 +441,7 @@ def register(mcp) -> None:
|
||||
update_note,
|
||||
find_duplicate_records,
|
||||
delete_note,
|
||||
notes_due_for_verification,
|
||||
mark_note_verified,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -54,6 +54,12 @@ async def create_process(
|
||||
) -> dict:
|
||||
"""Create a stored process (a reusable saved prompt).
|
||||
|
||||
FOLLOWED, OR READ? A process is invoked deliberately and worked through
|
||||
start to finish. If it should apply whether or not anyone invokes it, it
|
||||
is a rule (create_rule) — that is the whole difference between a procedure
|
||||
and a standing instruction. If it is knowledge to consult rather than
|
||||
steps to execute, it is a note (create_note).
|
||||
|
||||
AUTHOR IT AS A SHAPE, NOT A SCRIPT. A process's value is the accumulated
|
||||
procedure — the steps, the taxonomy, the quality bar, the failure modes
|
||||
worth guarding. It must not force anything the invoking conversation
|
||||
|
||||
@@ -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),
|
||||
**rulebooks_svc.rules_payload(applicable, user_id=uid, source="enter_project"),
|
||||
"open_tasks": [
|
||||
{
|
||||
"id": t.id, "title": t.title, "status": t.status,
|
||||
@@ -251,7 +251,7 @@ 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))
|
||||
data.update(rulebooks_svc.rules_payload(applicable, user_id=uid, source="get_project"))
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ 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, record_rule_surfaced
|
||||
|
||||
|
||||
# ── Rulebook CRUD ───────────────────────────────────────────────────────
|
||||
@@ -122,8 +123,9 @@ 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, "deleted_batch_id": batch,
|
||||
"message": f"Moved to trash. Restore with restore('{batch}')."}
|
||||
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}')."}
|
||||
|
||||
|
||||
# ── Topic CRUD ─────────────────────────────────────────────────────────
|
||||
@@ -192,8 +194,9 @@ 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, "deleted_batch_id": batch,
|
||||
"message": f"Moved to trash. Restore with restore('{batch}')."}
|
||||
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}')."}
|
||||
|
||||
|
||||
# ── Rule CRUD ──────────────────────────────────────────────────────────
|
||||
@@ -246,6 +249,14 @@ async def list_always_on_rules(project_id: int = 0) -> dict:
|
||||
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
|
||||
@@ -254,7 +265,25 @@ async def list_always_on_rules(project_id: int = 0) -> dict:
|
||||
"""
|
||||
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)}
|
||||
# AMBIENT source: the resident set, handed over whole. No ranker chose
|
||||
# these, so they must not land in the pull-through numerator's denominator
|
||||
# — but they must land SOMEWHERE, or the largest rule surface in the
|
||||
# product stays the one surface its own scoreboard cannot see (#3473).
|
||||
record_rule_surfaced(
|
||||
user_id=uid,
|
||||
rule_ids=[r.id for r in rules],
|
||||
source="list_always_on_rules",
|
||||
)
|
||||
return {
|
||||
"rules": [_rule_summary(r) for r in rules],
|
||||
"total": len(rules),
|
||||
# A marker for the set you are now holding. It is not for you to read:
|
||||
# the write-path hook carries it back and is told if these rules have
|
||||
# moved since. Deliberately NOT on rules_payload's applicable_rules —
|
||||
# that is a DIFFERENT set (subscription-derived), and one key name
|
||||
# over two sets is how a comparison starts reporting phantom changes.
|
||||
"rules_etag": rulebooks_svc.rules_etag(rules),
|
||||
}
|
||||
|
||||
|
||||
async def get_rule(rule_id: int) -> dict:
|
||||
@@ -269,6 +298,11 @@ 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)
|
||||
|
||||
|
||||
@@ -276,10 +310,66 @@ 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, force: bool = False,
|
||||
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
|
||||
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, the tier, 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
|
||||
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 —
|
||||
@@ -288,6 +378,13 @@ async def create_rule(
|
||||
rulebook+topic ceremony). If it's a standard a CATEGORY of projects shares,
|
||||
put it in a themed subscribed rulebook, not the always-on one.
|
||||
|
||||
Write it general WITHOUT hedging for the exceptions. A project that needs
|
||||
to strengthen, narrow or replace this rule writes its own and links it
|
||||
with relate_rules(kind="overrides"), and one that adds local specifics
|
||||
uses "elaborates" — so the general form does not have to anticipate every
|
||||
project it will ever reach. A rulebook rule padded with "unless…" clauses
|
||||
for two projects is two project rules that were never written.
|
||||
|
||||
Before writing a rule at all, check whether another entity already models
|
||||
the thing. A rule is prose an agent must remember and apply; the others
|
||||
are structure a tool can resolve, render and check. Visual standards are a
|
||||
@@ -314,6 +411,15 @@ async def create_rule(
|
||||
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
|
||||
statement are what a search is matched against, so it should
|
||||
carry the SYMPTOM, not just the situation: the words someone
|
||||
would actually type while stuck. Measured (note 3078): a rule
|
||||
whose trigger named only its situation did not surface at all
|
||||
for the problem it solves; adding the symptom to the same field
|
||||
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.
|
||||
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
|
||||
@@ -328,6 +434,23 @@ async def create_rule(
|
||||
cannot be followed and does not survive a rewording.
|
||||
why: Optional rationale — the reason the rule exists.
|
||||
how_to_apply: Optional operationalization — when / where it kicks in.
|
||||
verify_with: How to CHECK this rule is still true. Set it only when
|
||||
the rule asserts a fact about something outside your control — a
|
||||
runner's shell, a bot's config, whether a tool exists. Those go
|
||||
false silently, with nobody present. Give a command, a path, a
|
||||
URL or a query; something runnable beats prose, because prose
|
||||
has to be re-interpreted by whoever finds it.
|
||||
LEAVE IT EMPTY for a rule that is a DECISION — a preference, a
|
||||
standard, a way of working. A decision has no truth value: it
|
||||
changes when you change it, and you know that you did. An empty
|
||||
verify_with is not a gap, it is the marker for "there is nothing
|
||||
to go and check," and the whole signal is worthless the moment
|
||||
it is filled in out of tidiness.
|
||||
expires_when: The STATE under which this rule stops being true —
|
||||
"when the runner can be given a bash shell", "when the dashboard
|
||||
approval setting is turned off". Deliberately not a date: a
|
||||
constraint expires when the ground under it moves, not on a
|
||||
schedule. Pairs with verify_with; both empty is the normal case.
|
||||
order_index: Display order within the topic (default 0).
|
||||
force: Bypass the near-duplicate gate. By default, a title-identical rule
|
||||
already in this topic BLOCKS creation and returns its id so you update
|
||||
@@ -343,6 +466,7 @@ async def create_rule(
|
||||
title=title, statement=statement, when_to_apply=when_to_apply,
|
||||
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,
|
||||
)
|
||||
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
||||
|
||||
@@ -351,7 +475,8 @@ 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, force: bool = False,
|
||||
arose_from_id: int = 0, verify_with: str = "", expires_when: str = "",
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
"""Create a rule scoped to a single project (no rulebook needed).
|
||||
|
||||
@@ -363,6 +488,23 @@ 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);
|
||||
reusable code is a snippet (create_snippet). Each of those is structure a
|
||||
tool can resolve, render and check, where a rule is only prose someone
|
||||
has to remember and apply.
|
||||
|
||||
ONE RULE = ONE THING YOU COULD VIOLATE — see create_rule. A rule that
|
||||
STRICTENS or REPLACES an inherited one is not a fresh rule: write it, then
|
||||
relate_rules(kind="overrides") to the rule it supersedes, so the pair stays
|
||||
@@ -375,14 +517,45 @@ async def create_project_rule(
|
||||
title: Short imperative title. If empty, derived from the first ~50
|
||||
characters of statement.
|
||||
when_to_apply: WHEN this rule fires — the trigger, not the
|
||||
instruction. See create_rule; it decides the tier and it is how
|
||||
the rule is found at the moment it matters.
|
||||
tier: "always_on" (default) or "conditional" — see create_rule.
|
||||
instruction, and the rule's retrieval surface: name the SYMPTOM,
|
||||
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.
|
||||
arose_from_id: The note or task that CAUSED this rule.
|
||||
rule is about. Worth setting even on a project rule: it is what
|
||||
lets a conditional one surface when the project is working in
|
||||
that area.
|
||||
arose_from_id: The note or task that CAUSED this rule. Reach for it
|
||||
harder here than on a rulebook rule — a project rule usually
|
||||
comes from one traceable incident in this repo, where a family
|
||||
rule is more often a standing preference with no single origin.
|
||||
The link is what lets a later reader judge whether the incident
|
||||
still describes the project.
|
||||
why: Optional rationale — the reason the rule exists.
|
||||
how_to_apply: Optional operationalization — when / where it kicks in.
|
||||
verify_with: How to check this rule is still true — see create_rule.
|
||||
Set it when the rule asserts a fact about someone else's software;
|
||||
leave it empty when the rule is a decision. Project rules are the
|
||||
likelier home for a real check: they name this project's files,
|
||||
paths and quirks, which is exactly the kind of claim that rots.
|
||||
expires_when: The state under which the rule stops being true — see
|
||||
create_rule. A state, not a date.
|
||||
order_index: Display order within the project's rule list (default 0).
|
||||
force: Bypass the near-duplicate gate. By default, a title-identical rule
|
||||
already on this project BLOCKS creation and returns its id so you
|
||||
@@ -399,6 +572,7 @@ async def create_project_rule(
|
||||
title=derived_title, statement=statement, when_to_apply=when_to_apply,
|
||||
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,
|
||||
)
|
||||
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
||||
|
||||
@@ -407,12 +581,33 @@ 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,
|
||||
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.
|
||||
|
||||
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
|
||||
two fields without wiping the other six. Clearable: why, how_to_apply,
|
||||
when_to_apply, verify_with, expires_when, arose_from_id. Clearing and
|
||||
setting the same field in one call clears it first, so the new value wins.
|
||||
|
||||
Editing `verify_with` DROPS the rule's verification stamp. The stamp
|
||||
certifies a check, not a rule; once the check is reworded the old stamp
|
||||
vouches for something that no longer exists, so the rule re-enters the
|
||||
staleness sweep as never-verified.
|
||||
|
||||
Args:
|
||||
verify_with: How to check the rule is still true — set it when the
|
||||
rule asserts a fact about someone else's software, leave it empty
|
||||
when the rule is a decision. See create_rule.
|
||||
expires_when: The state under which the rule stops being true. A
|
||||
state, not a date. See create_rule.
|
||||
clear_fields: Names of fields to empty, as above.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
fields: dict = {}
|
||||
@@ -430,14 +625,87 @@ async def update_rule(
|
||||
fields["why"] = why
|
||||
if how_to_apply:
|
||||
fields["how_to_apply"] = how_to_apply
|
||||
if verify_with:
|
||||
fields["verify_with"] = verify_with
|
||||
if expires_when:
|
||||
fields["expires_when"] = expires_when
|
||||
if order_index >= 0:
|
||||
fields["order_index"] = order_index
|
||||
rule = await rulebooks_svc.update_rule(rule_id, uid, **fields)
|
||||
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()
|
||||
@@ -453,8 +721,9 @@ 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, "deleted_batch_id": batch,
|
||||
"message": f"Moved to trash. Restore with restore('{batch}')."}
|
||||
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}')."}
|
||||
|
||||
|
||||
# ── Subscriptions ──────────────────────────────────────────────────────
|
||||
@@ -619,6 +888,97 @@ async def unrelate_rules(relation_id: int) -> dict:
|
||||
raise ValueError(f"relation {relation_id} not found")
|
||||
return {"deleted": relation_id}
|
||||
|
||||
# ── The staleness sweep (milestone 312) ────────────────────────────────
|
||||
|
||||
async def rules_due_for_verification(
|
||||
older_than_days: int = 0, tier: str = "", never_only: bool = False,
|
||||
) -> dict:
|
||||
"""Which standing rules assert a FACT that nobody has confirmed lately.
|
||||
|
||||
A rulebook holds two kinds of thing. Most rules are DECISIONS — how the
|
||||
operator wants to work. They have no truth value and cannot rot. A few
|
||||
assert a fact about someone else's software: what a CI runner does, which
|
||||
tools exist, what a setting is currently set to. Those go false silently,
|
||||
with nobody present, and they keep being handed to every session as
|
||||
binding instructions long after they stopped being true.
|
||||
|
||||
This lists the second kind, oldest verification first, never-checked at
|
||||
the top. Each row carries the rule's `verify_with` in full — you are
|
||||
about to go and run it — plus `expires_when`, and `days_since_verified`.
|
||||
|
||||
Reach for it when you are curating the rulebook, when a rule's advice
|
||||
just contradicted what you observed, or periodically. Then, for each row:
|
||||
run the check, and call mark_rule_verified with what you found.
|
||||
|
||||
Rules with no `verify_with` never appear here. That is correct: they are
|
||||
decisions, and there is nothing to go and check. Do not "fix" their
|
||||
absence by giving them checks — the list is only worth reading while
|
||||
everything on it genuinely can go false.
|
||||
|
||||
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
|
||||
project scope, subscriptions, always-on rulebooks and exclusions, and a
|
||||
filter that missed one of those paths would UNDER-report — which is the
|
||||
exact failure this whole surface exists to prevent. Read the whole list.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
rules = await rulebooks_svc.rules_due_for_verification(
|
||||
uid, older_than_days=older_than_days, tier=tier, never_only=never_only,
|
||||
)
|
||||
return {
|
||||
"rules": [rulebooks_svc.verification_row(r) for r in rules],
|
||||
"total": len(rules),
|
||||
}
|
||||
|
||||
|
||||
async def mark_rule_verified(rule_id: int, still_true: bool = True) -> dict:
|
||||
"""Record that you ran a rule's check — and what it said.
|
||||
|
||||
Call this AFTER actually running the rule's `verify_with`, never on the
|
||||
strength of the rule sounding plausible. A stamp nobody earned is worse
|
||||
than no stamp: it moves the rule to the bottom of the sweep and buys it
|
||||
another long silence.
|
||||
|
||||
`still_true=False` writes NOTHING. A rule whose check failed is not in a
|
||||
special state to be recorded — it is WRONG, and the only honest next
|
||||
moves are to correct it, retire 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 rule said would end it.
|
||||
|
||||
Args:
|
||||
rule_id: the rule 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()
|
||||
rule = await rulebooks_svc.mark_rule_verified(rule_id, uid, still_true)
|
||||
if rule is None:
|
||||
raise ValueError(
|
||||
f"rule {rule_id} not found, or carries no verify_with "
|
||||
f"(nothing to verify is not the same as verified)"
|
||||
)
|
||||
data = await rulebooks_svc.rule_detail(uid, rule)
|
||||
if still_true:
|
||||
data["verified"] = True
|
||||
return data
|
||||
data["verified"] = False
|
||||
data["next"] = (
|
||||
"This rule is no longer true and is still binding on every session "
|
||||
"that loads it. Correct it with update_rule, retire it with "
|
||||
"delete_rule, or open a task to work out what replaced it. Its "
|
||||
"verified_at is deliberately untouched, so it stays at the top of "
|
||||
"rules_due_for_verification until one of those happens."
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
|
||||
@@ -630,5 +990,7 @@ def register(mcp) -> None:
|
||||
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)
|
||||
|
||||
@@ -14,6 +14,7 @@ from scribe.services.access import owner_names_for
|
||||
from scribe.services.embeddings import (
|
||||
DEFAULT_SIMILARITY_THRESHOLD, semantic_search_notes, semantic_search_rules,
|
||||
)
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summary
|
||||
|
||||
|
||||
@@ -23,7 +24,10 @@ async def _search_rules(uid: int, q: str, limit: int) -> dict:
|
||||
A rule hit carries `why` and `how_to_apply`: they are the operational half
|
||||
of a rule and the session-start payload never includes them, so a caller
|
||||
who went looking should get the whole thing rather than a summary they then
|
||||
have to re-fetch.
|
||||
have to re-fetch. It also carries the rule's check (`verify_with`,
|
||||
`expires_when`, `last_verified`) when it has one — a search hit is exactly
|
||||
the moment someone is about to act on a rule, and "this asserts a fact
|
||||
nobody has confirmed" is part of what the rule says.
|
||||
|
||||
Rules are not project-scoped the way notes are (a family rule belongs to no
|
||||
project), so `project_id` and `system_id` do not apply here.
|
||||
@@ -39,6 +43,14 @@ async def _search_rules(uid: int, q: str, limit: int) -> dict:
|
||||
"tier": rule.tier,
|
||||
"why": rule.why or "",
|
||||
"how_to_apply": rule.how_to_apply or "",
|
||||
"verify_with": rule.verify_with or "",
|
||||
"expires_when": rule.expires_when or "",
|
||||
# Only on a rule that carries a check; its absence means the
|
||||
# rule is a decision, not that nobody has looked.
|
||||
**(
|
||||
{"last_verified": rulebooks_svc.last_verified_label(rule)}
|
||||
if rule.verify_with else {}
|
||||
),
|
||||
"topic_id": rule.topic_id,
|
||||
"project_id": rule.project_id,
|
||||
"similarity": float(score),
|
||||
@@ -100,6 +112,7 @@ 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,
|
||||
@@ -107,12 +120,14 @@ 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"),
|
||||
)
|
||||
owners = await owner_names_for(
|
||||
{int(note.user_id) for _s, note in raw if note.user_id != uid}
|
||||
@@ -146,17 +161,54 @@ async def retrieval_telemetry(days: int = 30) -> dict:
|
||||
hand-probing the live instance, which is how the last such decision had to
|
||||
be made.
|
||||
|
||||
Two readouts, from the two tables built for them:
|
||||
Three readouts, from the three tables built for them:
|
||||
|
||||
`sources` — per retrieval surface (`auto_inject`, `write_path`,
|
||||
`mcp_search`, …), from `retrieval_logs`: `calls`, `zero_result_calls`,
|
||||
`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.
|
||||
`near_misses`, the `top_score` spread (p10/p50/p90/min/max),
|
||||
`avg_result_count` and `p90_duration_ms`.
|
||||
|
||||
`usage` — from `note_usage_events`, at the per-note grain
|
||||
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_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.
|
||||
|
||||
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
|
||||
`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
|
||||
@@ -170,6 +222,84 @@ async def retrieval_telemetry(days: int = 30) -> dict:
|
||||
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, `list_always_on_rules`, 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.
|
||||
|
||||
|
||||
@@ -115,6 +115,13 @@ async def create_snippet(
|
||||
"""Record a shape in the project's pattern library, so every later
|
||||
instance starts from it instead of re-deriving it.
|
||||
|
||||
IS THE SHAPE THE POINT, OR THE ADVICE? A snippet is code with a LOCATION —
|
||||
that is what lets it surface from the file someone is about to edit. If
|
||||
what wants recording is a standing instruction about how to work, it is a
|
||||
rule (create_rule); a procedure followed start to finish is a process
|
||||
(create_process); what you LEARNED rather than what to copy is a note
|
||||
(create_note).
|
||||
|
||||
Reach for this the FIRST time any shape is built — a component, a control,
|
||||
a route handler, a service class, a helper, a test scaffold — not only
|
||||
when something is judged "reusable": the builder of the first instance
|
||||
@@ -487,9 +494,19 @@ 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}
|
||||
return {"deleted": True, "id": snippet_id, "title": title}
|
||||
|
||||
|
||||
async def merge_snippets(target_id: int, source_ids: list[int]) -> dict:
|
||||
|
||||
@@ -336,7 +336,8 @@ async def list_system_records(
|
||||
slice, search(system_id=...) filters semantic search to this association.
|
||||
|
||||
Args:
|
||||
kind: filter by task_kind — 'issue', 'work', or 'plan'. Omit for all.
|
||||
kind: filter by task_kind — 'issue', 'work', 'spike' (or the retired
|
||||
'plan'). Omit for all.
|
||||
open_only: limit to tasks not done/cancelled (e.g. open issues only).
|
||||
"""
|
||||
uid = current_user_id()
|
||||
|
||||
@@ -23,6 +23,11 @@ from scribe.mcp.tools import systems as systems_tools
|
||||
from scribe.services import access as access_svc
|
||||
from scribe.services import dedup as dedup_svc
|
||||
from scribe.services import notes as notes_svc
|
||||
# Imported by NAME, not reached through notes_svc: minted_kind is pure
|
||||
# validation, not a service call, and a test that stubs the service module to
|
||||
# avoid the database would otherwise stub the validation too — turning a
|
||||
# guard into a MagicMock that approves anything.
|
||||
from scribe.services.notes import minted_kind
|
||||
from scribe.services import planning as planning_svc
|
||||
from scribe.services import rulebooks as rulebooks_svc
|
||||
from scribe.services import systems as systems_svc
|
||||
@@ -46,7 +51,8 @@ async def list_tasks(
|
||||
whenever a project is in scope so you list that project's tasks, not
|
||||
every project's. 0 = no filter (all projects — use only for a
|
||||
deliberate cross-project view).
|
||||
kind: Filter by task kind — 'work', 'plan', or 'issue'. Omit (empty) for all kinds.
|
||||
kind: Filter by task kind — 'work', 'issue', 'spike' (or the retired
|
||||
'plan'). Omit (empty) for all kinds.
|
||||
|
||||
Results are ordered by last-updated descending.
|
||||
"""
|
||||
@@ -97,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))
|
||||
data.update(rulebooks_svc.rules_payload(applicable, user_id=uid, source="get_task"))
|
||||
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
|
||||
@@ -129,6 +135,13 @@ async def create_task(
|
||||
) -> dict:
|
||||
"""Create a new task in Scribe.
|
||||
|
||||
IS ANYTHING ACTUALLY OWED? A task carries a status and someone is on the
|
||||
hook to move it. If nothing is owed — you are recording what you learned,
|
||||
decided or observed — that is a note (create_note), and filing it here
|
||||
leaves a to-do nobody will ever close. If the work is an ARC of several
|
||||
steps toward one goal, start_planning makes the milestone that holds
|
||||
them; a task is one step, not the plan.
|
||||
|
||||
Args:
|
||||
title: Task title (required).
|
||||
body: Markdown description / notes for the task.
|
||||
@@ -138,14 +151,24 @@ async def create_task(
|
||||
milestone_id: Place within a project milestone (0 = no milestone).
|
||||
parent_id: Make this a sub-task of another task (0 = top-level).
|
||||
tags: List of plain-string tags without # prefix.
|
||||
kind: 'work' (default) or 'issue'. An issue is corrective work — a
|
||||
problem you fixed or are fixing; record symptom → root cause → fix
|
||||
in the body. (Plans are milestones now — call start_planning to begin
|
||||
a plan; 'plan' is not a valid kind here.)
|
||||
kind: 'work' (default), 'issue', or 'spike'.
|
||||
An ISSUE is corrective work — a problem you fixed or are fixing;
|
||||
record symptom → root cause → fix in the body.
|
||||
A SPIKE is time-boxed and its output is KNOWLEDGE rather than a
|
||||
change: "find out whether the runner can be given a bash shell",
|
||||
"work out why the index is not used". It succeeds by producing an
|
||||
answer, so nothing ships at the end of it — which is why filing
|
||||
one as `work` makes a finished investigation look like an
|
||||
abandoned change. Reach for it when the honest deliverable is a
|
||||
finding, and say in the body what would close the box: a time, or
|
||||
the question being answered well enough to act on.
|
||||
(Plans are milestones now — call start_planning to begin a plan;
|
||||
'plan' is not a valid kind here.)
|
||||
system_ids: Ids of the project's Systems (reusable subsystem/area
|
||||
objects; see list_systems / create_system) to associate this task with.
|
||||
arose_from_id: For an issue, the id of the task/feature it arose from
|
||||
(provenance). 0 = none.
|
||||
arose_from_id: For an issue, the id of the task/feature it arose from;
|
||||
for a spike, the record that raised the question — including a
|
||||
standing rule whose check just failed. 0 = none.
|
||||
force: Bypass the near-duplicate gate. By default, if a title- or
|
||||
meaning-similar task already exists in the same project, creation is
|
||||
BLOCKED and the existing task's id is returned so you update it
|
||||
@@ -183,7 +206,7 @@ async def create_task(
|
||||
milestone_id=milestone_id or None,
|
||||
parent_id=parent_id or None,
|
||||
tags=tags,
|
||||
task_kind=kind,
|
||||
task_kind=minted_kind(kind),
|
||||
arose_from_id=arose_from_id or None,
|
||||
)
|
||||
if system_ids:
|
||||
@@ -203,6 +226,7 @@ async def update_task(
|
||||
milestone_id: int = 0,
|
||||
system_ids: list[int] | None = None,
|
||||
arose_from_id: int = 0,
|
||||
kind: str = "",
|
||||
) -> dict:
|
||||
"""Update an existing Scribe task. Only explicitly provided fields are changed.
|
||||
|
||||
@@ -222,6 +246,12 @@ async def update_task(
|
||||
(set-semantics). None = leave unchanged; [] = clear all.
|
||||
arose_from_id: Provenance (issue → originating task). 0 = leave unchanged,
|
||||
-1 = clear, positive = set.
|
||||
kind: Re-file this task as 'work', 'issue' or 'spike'. Omit (empty) to
|
||||
leave unchanged. Correcting a kind is ordinary — what a task turns
|
||||
out to BE is often clear only once it is under way, and a piece of
|
||||
work that becomes an investigation should say so. 'plan' is
|
||||
refused: plans are milestones (start_planning), and the value
|
||||
survives only so historical plan-tasks stay writable.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
fields: dict = {}
|
||||
@@ -247,6 +277,8 @@ async def update_task(
|
||||
fields["arose_from_id"] = None
|
||||
elif arose_from_id:
|
||||
fields["arose_from_id"] = arose_from_id
|
||||
if kind:
|
||||
fields["task_kind"] = minted_kind(kind)
|
||||
note = await notes_svc.update_note(uid, task_id, **fields)
|
||||
if note is None:
|
||||
raise ValueError(f"task {task_id} not found")
|
||||
@@ -298,7 +330,9 @@ async def start_planning(project_id: int, title: str) -> dict:
|
||||
Reach for this when the work has an ARC — several steps toward one goal,
|
||||
worth tracking as a unit. Work without one (a fix, a one-file change, a
|
||||
question answered) is a task, not a plan: create_task, drive its status, and
|
||||
record progress with add_task_log. A milestone holding a single step is
|
||||
record progress with add_task_log. A design or decision you are RECORDING
|
||||
rather than executing is a note (create_note) — a plan nobody is going to
|
||||
work through is a document filed in the place reserved for open work. A milestone holding a single step is
|
||||
ceremony, and it leaves the project with a plan that never meant anything.
|
||||
|
||||
Creates a MILESTONE that IS the plan: its `body` is seeded with a design
|
||||
@@ -330,11 +364,23 @@ 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_batch_id": batch,
|
||||
"message": f"Task {task_id} moved to trash. Restore with restore('{batch}')."}
|
||||
return {"deleted": task_id, "title": title, "deleted_batch_id": batch,
|
||||
"message": f'Task {task_id} ("{title}") moved to trash. '
|
||||
f"Restore with restore('{batch}')."}
|
||||
|
||||
|
||||
def register(mcp) -> None:
|
||||
|
||||
@@ -28,11 +28,13 @@ 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
|
||||
|
||||
@@ -51,7 +51,11 @@ 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:
|
||||
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.
|
||||
|
||||
- The embedding ROW could have been made polymorphic. The SEARCH could not.
|
||||
`semantic_search_notes` is a long function of Note-specific scoping —
|
||||
|
||||
@@ -23,6 +23,24 @@ class TaskPriority(str, enum.Enum):
|
||||
high = "high"
|
||||
|
||||
|
||||
class TaskKind(str, enum.Enum):
|
||||
"""What KIND of work a task is. Mirrors CHECK notes_task_kind_check.
|
||||
|
||||
Every value the COLUMN may hold, including `plan`. That is deliberate:
|
||||
plans became milestones in 0066, but historical plan-tasks still carry
|
||||
the value and must stay readable and writable. Refusing to MINT a new
|
||||
plan is a door policy (see the create/update task tools), not a
|
||||
statement about what the column accepts — conflating the two would make
|
||||
old rows unwritable, which is how a retired value turns into corrupt
|
||||
data.
|
||||
"""
|
||||
|
||||
work = "work"
|
||||
issue = "issue"
|
||||
spike = "spike"
|
||||
plan = "plan"
|
||||
|
||||
|
||||
class Note(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "notes"
|
||||
|
||||
@@ -61,18 +79,55 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
||||
# Note type — 'note' (default) or 'process' (a stored process). Task-ness is
|
||||
# tracked by `status`, not here. (person/place/list entity types removed 2026-07.)
|
||||
note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note")
|
||||
# Task sub-kind — 'work' (default), 'plan', or 'issue' (corrective work).
|
||||
# Task sub-kind — what KIND of work this is, not how it is going:
|
||||
# work (default) — ships a change
|
||||
# issue — corrective; something was broken (0065)
|
||||
# spike — time-boxed, and its output is KNOWLEDGE rather than a change;
|
||||
# it succeeds by producing an answer, and nothing ships (0091)
|
||||
# plan — retired since 0066 (plans are milestones), kept in the CHECK
|
||||
# so historical plan-tasks stay writable
|
||||
# Only meaningful when the note is a task (status is not None); ordinary
|
||||
# notes keep the 'work' default and ignore it. Orthogonal to note_type
|
||||
# (which is the note/entity axis).
|
||||
# (which is the note/entity axis). CHECK notes_task_kind_check (rule 36).
|
||||
task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work")
|
||||
# Queryable structured fields for typed records — currently snippets, whose
|
||||
# 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. NULL on every row written before migration 0070, so readers fall
|
||||
# back to parsing the body (see services/snippets.snippet_fields).
|
||||
# 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).
|
||||
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"),
|
||||
@@ -113,6 +168,14 @@ 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),
|
||||
}
|
||||
|
||||
@@ -42,8 +42,26 @@ 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)
|
||||
# [{"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)
|
||||
@@ -67,6 +85,7 @@ 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,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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)
|
||||
tier: 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 "",
|
||||
"tier": self.tier or "",
|
||||
"verify_with": self.verify_with or "",
|
||||
"expires_when": self.expires_when or "",
|
||||
})
|
||||
return out
|
||||
@@ -107,6 +107,26 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
||||
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
|
||||
# 312). 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: every stale rule the
|
||||
# 307 audit found was one, and no norm had rotted.
|
||||
#
|
||||
# `verify_with` is how to check the rule 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.
|
||||
#
|
||||
# Most rules 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 signal is only worth reading while that stays true.
|
||||
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
|
||||
)
|
||||
# The record that caused this rule — the edge notes and tasks already
|
||||
# have. Rule 46's `why` names note 2813 in prose; this is that link as a
|
||||
# field, so it survives a rewording of the paragraph.
|
||||
@@ -126,6 +146,9 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
||||
"tier": self.tier,
|
||||
"why": self.why or "",
|
||||
"how_to_apply": self.how_to_apply or "",
|
||||
"verify_with": self.verify_with or "",
|
||||
"expires_when": self.expires_when or "",
|
||||
"verified_at": iso(self.verified_at),
|
||||
"arose_from_id": self.arose_from_id,
|
||||
"order_index": self.order_index,
|
||||
"created_at": iso(self.created_at),
|
||||
|
||||
@@ -10,6 +10,61 @@ 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({"version": os.environ.get("APP_VERSION", "dev")})
|
||||
return jsonify(build_version_payload())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unified Knowledge endpoint — notes, tasks, plans, and processes in one queryable feed."""
|
||||
"""Unified Knowledge endpoint — every record kind in one queryable feed."""
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, jsonify, request
|
||||
@@ -6,12 +6,18 @@ 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")
|
||||
|
||||
_VALID_TYPES = {"note", "task", "plan", "process"}
|
||||
# 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_SORTS = {"modified", "created", "alpha", "type"}
|
||||
|
||||
|
||||
@@ -21,7 +27,9 @@ async def list_knowledge():
|
||||
"""Return paginated knowledge objects with optional filtering.
|
||||
|
||||
Query params:
|
||||
type — one of note|task|plan|process (omit for all)
|
||||
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.
|
||||
tags — comma-separated tag filter (AND logic)
|
||||
sort — modified|created|alpha|type (default: modified)
|
||||
q — search query (semantic when provided, keyword fallback)
|
||||
@@ -127,7 +135,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 (excludes tasks)."""
|
||||
"""Return all tags used across knowledge objects, narrowed to one facet."""
|
||||
uid = get_current_user_id()
|
||||
note_type = request.args.get("type", "").strip().lower() or None
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@ 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
|
||||
@@ -112,6 +115,8 @@ 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
|
||||
@@ -248,7 +253,14 @@ 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"):
|
||||
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",
|
||||
):
|
||||
if key in data:
|
||||
fields[key] = data[key]
|
||||
if "due_date" in data:
|
||||
@@ -490,3 +502,62 @@ 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)
|
||||
|
||||
@@ -101,6 +101,46 @@ async def autoinject_retrieve():
|
||||
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)
|
||||
|
||||
|
||||
@plugin_bp.get("/prior-art")
|
||||
@login_required
|
||||
async def write_path_prior_art():
|
||||
@@ -138,6 +178,11 @@ async def write_path_prior_art():
|
||||
or `canon:<snippet_id>`) already named this
|
||||
session by the ledger arm (#2900); its own
|
||||
channel, like the two above.
|
||||
rules_etag (opt) — the marker the session was given when it loaded
|
||||
its always-on rules (milestone 323). Sent back
|
||||
so the server can say whether those rules have
|
||||
MOVED since. Absent means the hook has nothing
|
||||
stored, which is silence, not a mismatch.
|
||||
shapes (opt) — comma-separated `kind:name` definitions the hook
|
||||
found in (or enclosing) the payload, kind being
|
||||
css|sym. The shape ledger's write-path feed
|
||||
@@ -157,6 +202,7 @@ async def write_path_prior_art():
|
||||
p.strip() for p in (request.args.get("exclude_derive") or "").split(",") if p.strip()
|
||||
]
|
||||
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
|
||||
rules_etag = (request.args.get("rules_etag") or "").strip()
|
||||
shapes = _parse_shapes(request.args.get("shapes") or "")
|
||||
api_key = getattr(g, "api_key", None)
|
||||
may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
|
||||
@@ -168,6 +214,7 @@ async def write_path_prior_art():
|
||||
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
|
||||
exclude_derive=exclude_derive,
|
||||
exclude_rule_ids=exclude_rule_ids,
|
||||
rules_etag=rules_etag,
|
||||
)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ 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")
|
||||
|
||||
@@ -136,13 +139,24 @@ 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=get_current_user_id(),
|
||||
user_id=uid,
|
||||
rulebook_id=rulebook_id,
|
||||
topic_id=topic_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
return jsonify({"rules": [r.to_dict() for r in rows]})
|
||||
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})
|
||||
|
||||
|
||||
@rulebooks_bp.post("/rulebook-topics/<int:topic_id>/rules")
|
||||
@@ -165,6 +179,8 @@ async def create_rule(topic_id: int):
|
||||
when_to_apply=data.get("when_to_apply", ""),
|
||||
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", ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
@@ -180,6 +196,11 @@ 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))
|
||||
|
||||
|
||||
@@ -191,14 +212,55 @@ 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", "tier", "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
|
||||
# service normalises "" to NULL for every nullable text column. The MCP
|
||||
# door needs the explicit list only because "" already means "unchanged"
|
||||
# there — two idioms, one outcome.
|
||||
rule = await rulebooks_svc.update_rule(rule_id, uid, **fields)
|
||||
if rule is None:
|
||||
return jsonify({"error": "rule not found"}), 404
|
||||
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):
|
||||
@@ -375,9 +437,64 @@ async def create_project_rule(project_id: int):
|
||||
when_to_apply=data.get("when_to_apply", ""),
|
||||
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", ""),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 404
|
||||
return jsonify(await rulebooks_svc.rule_detail(
|
||||
get_current_user_id(), rule, data.get("system_ids"),
|
||||
)), 201
|
||||
|
||||
|
||||
# ── The staleness sweep (milestone 312) ────────────────────────────────
|
||||
|
||||
@rulebooks_bp.get("/rules-due-for-verification")
|
||||
@login_required
|
||||
async def rules_due_for_verification():
|
||||
"""Rules that carry a check, oldest verification first, never-checked top.
|
||||
|
||||
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()
|
||||
args = request.args
|
||||
try:
|
||||
older = int(args.get("older_than_days", 0) or 0)
|
||||
except ValueError:
|
||||
return jsonify({"error": "older_than_days must be an integer"}), 400
|
||||
try:
|
||||
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:
|
||||
# An unrecognised tier is a 400, not a silently narrowed result set:
|
||||
# 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({
|
||||
"rules": [rulebooks_svc.verification_row(r) for r in rules],
|
||||
"total": len(rules),
|
||||
})
|
||||
|
||||
|
||||
@rulebooks_bp.post("/rules/<int:rule_id>/verify")
|
||||
@login_required
|
||||
async def mark_rule_verified(rule_id: int):
|
||||
"""Record that the rule's check was run. Body: {"still_true": bool}.
|
||||
|
||||
`still_true: false` writes nothing — a rule whose check failed is wrong,
|
||||
not in a recordable state — so it stays at the top of the sweep.
|
||||
"""
|
||||
data = await request.get_json() or {}
|
||||
uid = get_current_user_id()
|
||||
still_true = data.get("still_true", True)
|
||||
rule = await rulebooks_svc.mark_rule_verified(rule_id, uid, bool(still_true))
|
||||
if rule is None:
|
||||
return jsonify({"error": "rule not found, or carries no verify_with"}), 404
|
||||
payload = await rulebooks_svc.rule_detail(uid, rule)
|
||||
payload["verified"] = bool(still_true)
|
||||
return jsonify(payload)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user