Compare commits
80
Commits
63036ed52e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c4ace5199 | ||
|
|
277f5df515 | ||
|
|
6495236569 | ||
|
|
ab14f783e1 | ||
|
|
1cfbf43ccd | ||
|
|
a165483b92 | ||
|
|
53df6742cb | ||
|
|
e7c1af32a0 | ||
|
|
277aea58e4 | ||
|
|
5e19a1b028 | ||
|
|
be08edcd7e | ||
|
|
7a2aff7bc1 | ||
|
|
0808e8259a | ||
|
|
950c93c5d4 | ||
|
|
21a5831479 | ||
|
|
4a85220aea | ||
|
|
dd1e6e2645 | ||
|
|
14ff41faf5 | ||
|
|
c3ecdf0972 | ||
|
|
1a34363059 | ||
|
|
67df41ae00 | ||
|
|
30d87e461a | ||
|
|
0915c48bb0 | ||
|
|
8be555d6dd | ||
|
|
aea7b63b62 | ||
|
|
48804c437d | ||
|
|
154a5de13e | ||
|
|
5b02908dfd | ||
|
|
2ee24b9d2b | ||
|
|
8b9b3a1d9b | ||
|
|
34cd389371 | ||
|
|
6627cfc2f0 | ||
|
|
238510080e | ||
|
|
8901c904a9 | ||
|
|
70761b16d9 | ||
|
|
8f7f447fda | ||
|
|
111eef7e30 | ||
|
|
8826be7a91 | ||
|
|
e029a7db64 | ||
|
|
9bb59b73ba | ||
|
|
f5a3643da8 | ||
|
|
64cb719a12 | ||
|
|
f1896bfe9d | ||
|
|
ea972ac3f7 | ||
|
|
0d4b155699 | ||
|
|
05da26eb24 | ||
|
|
70d84fbfd7 | ||
|
|
9d8104f7a5 | ||
|
|
7827b4ce63 | ||
|
|
69ce7afc45 | ||
|
|
b267037911 | ||
|
|
7985f8c7d7 | ||
|
|
efabba58dd | ||
|
|
5c9bb40777 | ||
|
|
a8b2040216 | ||
|
|
0704988528 | ||
|
|
255c43a8fe | ||
|
|
6fa66f202b | ||
|
|
7a0dc93270 | ||
|
|
9006affda8 | ||
|
|
9657478500 | ||
|
|
1d65e98ac2 | ||
|
|
1ec44071d2 | ||
|
|
b51621fca7 | ||
|
|
8489206224 | ||
|
|
4736a0a0ba | ||
|
|
700ef20eb0 | ||
|
|
b134fe9aa1 | ||
|
|
a6ef3a6a5a | ||
|
|
2263fd04a4 | ||
|
|
2065781302 | ||
|
|
454c617ca0 | ||
|
|
f80401d58e | ||
|
|
d0a2733cb6 | ||
|
|
93d660b710 | ||
|
|
ce1376edc9 | ||
|
|
0c74dc8275 | ||
|
|
a0b54ff6a3 | ||
|
|
16805ca22c | ||
|
|
056c7c75da |
@@ -46,8 +46,6 @@ on:
|
|||||||
- "alembic/**"
|
- "alembic/**"
|
||||||
- "alembic.ini"
|
- "alembic.ini"
|
||||||
- "Dockerfile"
|
- "Dockerfile"
|
||||||
- "assets/**"
|
|
||||||
- "fable-mcp/**"
|
|
||||||
# The plugin ships straight from this repo — installs fetch it via
|
# The plugin ships straight from this repo — installs fetch it via
|
||||||
# .claude-plugin/marketplace.json, NOT from the image. So a push here is
|
# .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
|
# the release, with no build step in between. Omitting these paths meant
|
||||||
@@ -279,6 +277,21 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
UV_PROJECT_ENVIRONMENT: /opt/venv
|
UV_PROJECT_ENVIRONMENT: /opt/venv
|
||||||
run: uv sync --locked --extra dev
|
run: uv sync --locked --extra dev
|
||||||
|
# Standing answers to the checks carried by rules 81 and 79 — two facts
|
||||||
|
# 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)
|
- name: Integration suite (resolve service IP, migrate, test)
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
@@ -289,8 +302,9 @@ jobs:
|
|||||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||||
test -n "$PG_IP"
|
test -n "$PG_IP"
|
||||||
export DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test"
|
export DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test"
|
||||||
# Wait for Postgres to accept connections (busybox sh — the runner
|
# Wait for Postgres to accept connections. The run: shell is dash
|
||||||
# default — has no bash /dev/tcp, so use Python).
|
# (/bin/sh -> /usr/bin/dash on this Debian-based image, confirmed by
|
||||||
|
# the step above) — no bash /dev/tcp, so use Python.
|
||||||
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
||||||
import socket, sys, time
|
import socket, sys, time
|
||||||
for _ in range(30):
|
for _ in range(30):
|
||||||
@@ -327,6 +341,14 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- 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
|
- name: Generate image tags and version
|
||||||
id: tags
|
id: tags
|
||||||
@@ -339,7 +361,27 @@ jobs:
|
|||||||
# the runner log on commit 2a374d9.
|
# the runner log on commit 2a374d9.
|
||||||
run: |
|
run: |
|
||||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
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
|
case "${{ github.ref }}" in
|
||||||
refs/heads/dev)
|
refs/heads/dev)
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||||
@@ -348,15 +390,17 @@ jobs:
|
|||||||
# main IS the production line: publish :latest (plus the :<sha>
|
# main IS the production line: publish :latest (plus the :<sha>
|
||||||
# set above). No separate :main tag.
|
# set above). No separate :main tag.
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
||||||
BUILD_VERSION="main"
|
CHANNEL="stable"
|
||||||
;;
|
;;
|
||||||
refs/tags/*)
|
refs/tags/*)
|
||||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||||
BUILD_VERSION="${{ github.ref_name }}"
|
CHANNEL="stable"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
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
|
- name: Free disk space
|
||||||
# Self-hosted runner housekeeping. Two-step cleanup:
|
# Self-hosted runner housekeeping. Two-step cleanup:
|
||||||
@@ -386,7 +430,15 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
provenance: false
|
provenance: false
|
||||||
tags: ${{ steps.tags.outputs.value }}
|
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
|
# Registry-backed layer cache. Pull from :cache to prime
|
||||||
# BuildKit, push updated layers back to :cache so the next
|
# BuildKit, push updated layers back to :cache so the next
|
||||||
# build starts warm even if the runner's local cache was
|
# 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
|
# Ensure Python finds the source tree (where static files live) before site-packages
|
||||||
ENV PYTHONPATH=/app/src
|
ENV PYTHONPATH=/app/src
|
||||||
|
|
||||||
# Version is injected at build time via --build-arg BUILD_VERSION=YY.MM.DD.N
|
# THREE VALUES, NEVER FOLDED TOGETHER (rule 149), plus the commit.
|
||||||
# Falls back to "dev" for local / untagged builds
|
#
|
||||||
|
# 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_VERSION=dev
|
||||||
|
ARG BUILD_KEY=
|
||||||
|
ARG BUILD_CHANNEL=
|
||||||
|
ARG BUILD_COMMIT=
|
||||||
ENV APP_VERSION=$BUILD_VERSION
|
ENV APP_VERSION=$BUILD_VERSION
|
||||||
|
ENV APP_BUILD_KEY=$BUILD_KEY
|
||||||
|
ENV APP_CHANNEL=$BUILD_CHANNEL
|
||||||
|
ENV APP_COMMIT=$BUILD_COMMIT
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
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 ---
|
# --- Docker ---
|
||||||
|
|
||||||
@@ -36,3 +36,12 @@ test:
|
|||||||
|
|
||||||
# Run all checks in one shot (mirrors what CI does)
|
# Run all checks in one shot (mirrors what CI does)
|
||||||
check: lint typecheck test
|
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,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 { useShortcuts } from "@/composables/useShortcuts";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import { apiGet, apiPut } from "@/api/client";
|
import { apiPut } from "@/api/client";
|
||||||
|
import { fetchVersion } from "@/api/version";
|
||||||
|
|
||||||
useTheme();
|
useTheme();
|
||||||
|
|
||||||
const router = useRouter();
|
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 authStore = useAuthStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
|
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
|
||||||
@@ -119,10 +127,12 @@ onMounted(async () => {
|
|||||||
startAppServices();
|
startAppServices();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await apiGet<{ version: string }>("/api/version");
|
appVersion.value = (await fetchVersion()).version;
|
||||||
appVersion.value = data.version;
|
|
||||||
} catch {
|
} 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">
|
<div id="main-content" class="app-content">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Keyboard shortcuts overlay -->
|
<!-- Keyboard shortcuts overlay -->
|
||||||
|
|||||||
+127
-32
@@ -52,41 +52,121 @@ export function apiErrorMessage(e: unknown, fallback: string): string {
|
|||||||
return fallback;
|
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);
|
return handleResponse<T>(res, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
/** JSON body headers — the three write verbs sent an identical literal each. */
|
||||||
const res = await fetch(path, {
|
const JSON_HEADERS = { "Content-Type": "application/json" };
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
export function apiGet<T>(path: string, opts?: RequestOpts): Promise<T> {
|
||||||
body: JSON.stringify(body),
|
return request<T>(path, {}, opts);
|
||||||
});
|
|
||||||
return handleResponse<T>(res, path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
|
export function apiPost<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
||||||
const res = await fetch(path, {
|
return request<T>(path, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
||||||
method: "PUT",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
return handleResponse<T>(res, path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
export function apiPut<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
||||||
const res = await fetch(path, {
|
return request<T>(path, { method: "PUT", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
return handleResponse<T>(res, path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDelete(path: string): Promise<void> {
|
export function apiPatch<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
||||||
const res = await fetch(path, { method: "DELETE" });
|
return request<T>(path, { method: "PATCH", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
||||||
return handleResponse<void>(res, path);
|
}
|
||||||
|
|
||||||
|
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 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) {
|
if (!res.ok) {
|
||||||
let body: Record<string, unknown> = {};
|
let body: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
@@ -318,11 +405,19 @@ export async function apiStreamPost(
|
|||||||
body: unknown,
|
body: unknown,
|
||||||
onChunk: (data: Record<string, unknown>) => void
|
onChunk: (data: Record<string, unknown>) => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch(path, {
|
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
|
||||||
method: "POST",
|
const connect = connectDeadline(new AbortController().signal);
|
||||||
headers: { "Content-Type": "application/json" },
|
let res: Response;
|
||||||
body: JSON.stringify(body),
|
try {
|
||||||
});
|
res = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: JSON_HEADERS,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: connect.signal,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
connect.settle();
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let errBody: Record<string, unknown> = {};
|
let errBody: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { RecordUsage } from "@/types/usage";
|
||||||
|
|
||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||||
|
|
||||||
/** How a rule reaches a session (milestone 307). */
|
/** How a rule reaches a session (milestone 307). */
|
||||||
@@ -96,6 +98,13 @@ export interface RuleHeader {
|
|||||||
* A date (YYYY-MM-DD), or the literal "never".
|
* A date (YYYY-MM-DD), or the literal "never".
|
||||||
*/
|
*/
|
||||||
last_verified?: string;
|
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 {
|
export interface ApplicableRules {
|
||||||
@@ -228,6 +237,49 @@ export async function unrelateRules(relationId: number): Promise<void> {
|
|||||||
return apiDelete(`/api/rule-relations/${relationId}`);
|
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> {
|
export async function deleteRule(id: number): Promise<void> {
|
||||||
return apiDelete(`/api/rules/${id}`);
|
return apiDelete(`/api/rules/${id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { RecordUsage } from "@/types/usage";
|
||||||
|
|
||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||||
|
|
||||||
/** One canonical location of a reusable thing. A snippet that unifies several
|
/** One canonical location of a reusable thing. A snippet that unifies several
|
||||||
@@ -50,15 +52,11 @@ export interface Snippet {
|
|||||||
owner?: string | null;
|
owner?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How often a record was put in front of an agent versus actually opened.
|
/** Kept as a name because every consumer here says "snippet usage" — but it IS
|
||||||
* A high `surfaced_count` with `pull_count: 0` is dead weight — it occupies a
|
* the shared shape, since rules answer the same question off their own table
|
||||||
* slot in every future auto-inject menu while never being used. */
|
* (milestone 333). The reasoning lives on `RecordUsage`; duplicating the four
|
||||||
export interface SnippetUsage {
|
* fields here is how the two drift. */
|
||||||
surfaced_count: number;
|
export type SnippetUsage = RecordUsage;
|
||||||
pull_count: number;
|
|
||||||
last_surfaced_at: string | null;
|
|
||||||
last_pulled_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result of the last drift check — does the recorded location and code still
|
/** 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
|
* 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); }
|
.required { color: var(--fs-error); }
|
||||||
.field-hint { margin: 0.3rem 0 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
.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: 1px solid var(--fs-error);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--fs-error);
|
color: var(--fs-error-fg);
|
||||||
}
|
}
|
||||||
.diff-view {
|
.diff-view {
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--fs-border-color);
|
||||||
@@ -147,11 +147,11 @@
|
|||||||
}
|
}
|
||||||
.diff-delete {
|
.diff-delete {
|
||||||
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
|
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
|
||||||
color: var(--fs-error);
|
color: var(--fs-error-fg);
|
||||||
}
|
}
|
||||||
.diff-insert {
|
.diff-insert {
|
||||||
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
|
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
|
||||||
color: var(--fs-success);
|
color: var(--fs-success-fg);
|
||||||
}
|
}
|
||||||
.diff-equal {
|
.diff-equal {
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--fs-text-tertiary);
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
file used to read, and it is deliberate: the light palette was never specified
|
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.
|
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,
|
Only 12 tokens differ between modes. Everything else — spacing, type, motion,
|
||||||
radius, and every derived colour — is stated once, because a value built with
|
radius, and every derived colour — is stated once, because a value built with
|
||||||
var() resolves where it is USED, not where it is written.
|
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-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-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-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-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: 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);
|
--fs-glow-cta-hover: 0 4px 24px color-mix(in srgb, var(--fs-accent) 65%, transparent);
|
||||||
@@ -78,10 +86,13 @@
|
|||||||
/* priority */
|
/* priority */
|
||||||
--fs-priority-low: var(--fs-info);
|
--fs-priority-low: var(--fs-info);
|
||||||
--fs-priority-low-bg: color-mix(in srgb, var(--fs-priority-low) 12%, transparent);
|
--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: var(--fs-warning);
|
||||||
--fs-priority-medium-bg: color-mix(in srgb, var(--fs-priority-medium) 12%, transparent);
|
--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: var(--fs-error);
|
||||||
--fs-priority-high-bg: color-mix(in srgb, var(--fs-priority-high) 12%, transparent);
|
--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 */
|
/* radius */
|
||||||
--fs-radius-sm: 4px; /* pills, tags, code spans */
|
--fs-radius-sm: 4px; /* pills, tags, code spans */
|
||||||
@@ -92,8 +103,11 @@
|
|||||||
|
|
||||||
/* semantic */
|
/* semantic */
|
||||||
--fs-success: var(--fs-action-primary);
|
--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: #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: #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-info: #3D5A6E;
|
||||||
--fs-destructive: #6B2118; /* irreversible — deliberately not the error colour */
|
--fs-destructive: #6B2118; /* irreversible — deliberately not the error colour */
|
||||||
|
|
||||||
@@ -116,12 +130,16 @@
|
|||||||
/* status */
|
/* status */
|
||||||
--fs-status-todo: var(--fs-border-color);
|
--fs-status-todo: var(--fs-border-color);
|
||||||
--fs-status-todo-bg: color-mix(in srgb, var(--fs-status-todo) 12%, transparent);
|
--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: var(--fs-accent);
|
||||||
--fs-status-in-progress-bg: color-mix(in srgb, var(--fs-status-in-progress) 12%, transparent);
|
--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: var(--fs-success);
|
||||||
--fs-status-done-bg: color-mix(in srgb, var(--fs-status-done) 12%, transparent);
|
--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-overdue: var(--fs-error);
|
||||||
--fs-status-cancelled: var(--fs-text-tertiary); /* set aside, not failed */
|
--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 */
|
/* surface */
|
||||||
--fs-surface-page: #14171A; /* page bg, deepest surface */
|
--fs-surface-page: #14171A; /* page bg, deepest surface */
|
||||||
@@ -134,7 +152,9 @@
|
|||||||
/* text */
|
/* text */
|
||||||
--fs-text-primary: #E8E4D8; /* body, headings, labels — inverts by mode */
|
--fs-text-primary: #E8E4D8; /* body, headings, labels — inverts by mode */
|
||||||
--fs-text-secondary: #C2BFB4;
|
--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: #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 */
|
--fs-text-on-action: #E8E4D8; /* text on a filled colour — NOT mode-dependent */
|
||||||
|
|
||||||
/* type */
|
/* type */
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
border-color: var(--fs-accent);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.ctx-crumb-project {
|
.ctx-crumb-project {
|
||||||
color: var(--fs-accent);
|
color: var(--fs-accent-fg);
|
||||||
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
||||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ router.afterEach(() => {
|
|||||||
background: var(--fs-accent-soft);
|
background: var(--fs-accent-soft);
|
||||||
}
|
}
|
||||||
.nav-link.router-link-active {
|
.nav-link.router-link-active {
|
||||||
color: var(--fs-accent);
|
color: var(--fs-accent-fg);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: color-mix(in srgb, var(--fs-accent) 25%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 25%, transparent);
|
||||||
box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||||
@@ -257,7 +257,7 @@ router.afterEach(() => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--fs-accent);
|
color: var(--fs-accent-fg);
|
||||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||||
padding: 0.1rem 0.35rem;
|
padding: 0.1rem 0.35rem;
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
|
|||||||
@@ -137,12 +137,12 @@ function markerFor(type: DiffLine['type']): string {
|
|||||||
|
|
||||||
.diff-delete {
|
.diff-delete {
|
||||||
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
|
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
|
||||||
color: var(--fs-error);
|
color: var(--fs-error-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-insert {
|
.diff-insert {
|
||||||
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
|
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
|
||||||
color: var(--fs-success);
|
color: var(--fs-success-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-equal {
|
.diff-equal {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
|
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
|
||||||
import DiffView from "@/components/DiffView.vue";
|
import DiffView from "@/components/DiffView.vue";
|
||||||
import type { DiffLine } from "@/composables/useAssist";
|
import { computeDiff, type DiffLine } from "@/utils/diff";
|
||||||
import { fmtStamp } from "@/utils/dateFormat";
|
import { fmtStamp } from "@/utils/dateFormat";
|
||||||
|
|
||||||
interface NoteVersion {
|
interface NoteVersion {
|
||||||
@@ -33,28 +33,8 @@ const loadingDetail = ref(false);
|
|||||||
|
|
||||||
const diff = computed<DiffLine[]>(() => {
|
const diff = computed<DiffLine[]>(() => {
|
||||||
if (!selectedVersion.value?.body) return [];
|
if (!selectedVersion.value?.body) return [];
|
||||||
const a = props.currentBody;
|
|
||||||
const b = selectedVersion.value.body;
|
|
||||||
|
|
||||||
const aLines = a.split('\n');
|
return computeDiff(props.currentBody, selectedVersion.value.body);
|
||||||
const bLines = b.split('\n');
|
|
||||||
const m = aLines.length, n = bLines.length;
|
|
||||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
||||||
for (let i = m - 1; i >= 0; i--)
|
|
||||||
for (let j = n - 1; j >= 0; j--)
|
|
||||||
dp[i][j] = aLines[i] === bLines[j]
|
|
||||||
? dp[i+1][j+1] + 1
|
|
||||||
: Math.max(dp[i+1][j], dp[i][j+1]);
|
|
||||||
const result: DiffLine[] = [];
|
|
||||||
let i = 0, j = 0;
|
|
||||||
while (i < m && j < n) {
|
|
||||||
if (aLines[i] === bLines[j]) { result.push({ type: 'equal', text: aLines[i++] }); j++; }
|
|
||||||
else if (dp[i+1][j] >= dp[i][j+1]) result.push({ type: 'delete', text: aLines[i++] });
|
|
||||||
else result.push({ type: 'insert', text: bLines[j++] });
|
|
||||||
}
|
|
||||||
while (i < m) result.push({ type: 'delete', text: aLines[i++] });
|
|
||||||
while (j < n) result.push({ type: 'insert', text: bLines[j++] });
|
|
||||||
return result;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadVersions() {
|
async function loadVersions() {
|
||||||
|
|||||||
@@ -227,11 +227,11 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
.iap-diff-equal { color: var(--fs-text-tertiary); }
|
.iap-diff-equal { color: var(--fs-text-tertiary); }
|
||||||
.iap-diff-delete {
|
.iap-diff-delete {
|
||||||
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
|
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
|
||||||
color: var(--fs-error);
|
color: var(--fs-error-fg);
|
||||||
}
|
}
|
||||||
.iap-diff-insert {
|
.iap-diff-insert {
|
||||||
background: color-mix(in srgb, var(--fs-success) 10%, transparent);
|
background: color-mix(in srgb, var(--fs-success) 10%, transparent);
|
||||||
color: var(--fs-success);
|
color: var(--fs-success-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iap-diff-marker {
|
.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 {
|
.md-btn.active {
|
||||||
background: color-mix(in srgb, var(--fs-accent) 14%, transparent);
|
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);
|
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<{
|
const props = defineProps<{
|
||||||
priority: TaskPriority;
|
priority: TaskPriority;
|
||||||
|
/** Dense surfaces — see StatusBadge. */
|
||||||
|
compact?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const labels: Record<TaskPriority, string> = {
|
const labels: Record<TaskPriority, string> = {
|
||||||
@@ -16,7 +18,7 @@ const labels: Record<TaskPriority, string> = {
|
|||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
v-if="props.priority !== 'none'"
|
v-if="props.priority !== 'none'"
|
||||||
:class="['priority-badge', `priority-${props.priority}`]"
|
:class="['priority-badge', `priority-${props.priority}`, { compact }]"
|
||||||
>
|
>
|
||||||
{{ labels[props.priority] }}
|
{{ labels[props.priority] }}
|
||||||
</span>
|
</span>
|
||||||
@@ -28,20 +30,28 @@ const labels: Record<TaskPriority, string> = {
|
|||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 0.75rem;
|
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;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.025em;
|
letter-spacing: 0.025em;
|
||||||
}
|
}
|
||||||
|
.compact {
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
|
}
|
||||||
.priority-low {
|
.priority-low {
|
||||||
background: var(--fs-priority-low-bg);
|
background: var(--fs-priority-low-bg);
|
||||||
color: var(--fs-priority-low);
|
color: var(--fs-priority-low-fg);
|
||||||
}
|
}
|
||||||
.priority-medium {
|
.priority-medium {
|
||||||
background: var(--fs-priority-medium-bg);
|
background: var(--fs-priority-medium-bg);
|
||||||
color: var(--fs-priority-medium);
|
color: var(--fs-priority-medium-fg);
|
||||||
}
|
}
|
||||||
.priority-high {
|
.priority-high {
|
||||||
background: var(--fs-priority-high-bg);
|
background: var(--fs-priority-high-bg);
|
||||||
color: var(--fs-priority-high);
|
color: var(--fs-priority-high-fg);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ watch(() => [props.projectId, props.designSystemId], run);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pdt-clean {
|
.pdt-clean {
|
||||||
color: var(--fs-status-done);
|
color: var(--fs-status-done-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdt-summary {
|
.pdt-summary {
|
||||||
@@ -206,12 +206,12 @@ watch(() => [props.projectId, props.designSystemId], run);
|
|||||||
|
|
||||||
.pdt-tag.unknown {
|
.pdt-tag.unknown {
|
||||||
background: var(--fs-priority-high-bg);
|
background: var(--fs-priority-high-bg);
|
||||||
color: var(--fs-priority-high);
|
color: var(--fs-priority-high-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdt-tag.local {
|
.pdt-tag.local {
|
||||||
background: var(--fs-priority-medium-bg);
|
background: var(--fs-priority-medium-bg);
|
||||||
color: var(--fs-priority-medium);
|
color: var(--fs-priority-medium-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdt-tag.superseded {
|
.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<{
|
const props = defineProps<{
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
clickable?: boolean;
|
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: [] }>();
|
defineEmits<{ click: [] }>();
|
||||||
|
|
||||||
const labels: Record<TaskStatus, string> = {
|
const labels: Record<TaskStatus, string> = {
|
||||||
todo: "Todo",
|
todo: "Todo",
|
||||||
in_progress: "In Progress",
|
in_progress: "In progress",
|
||||||
done: "Done",
|
done: "Done",
|
||||||
cancelled: "Cancelled",
|
cancelled: "Cancelled",
|
||||||
};
|
};
|
||||||
@@ -18,7 +21,7 @@ const labels: Record<TaskStatus, string> = {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
:class="['status-badge', `status-${props.status}`, { clickable }]"
|
:class="['status-badge', `status-${props.status}`, { clickable, compact }]"
|
||||||
@click="clickable ? $emit('click') : undefined"
|
@click="clickable ? $emit('click') : undefined"
|
||||||
:role="clickable ? 'button' : undefined"
|
:role="clickable ? 'button' : undefined"
|
||||||
:tabindex="clickable ? 0 : undefined"
|
:tabindex="clickable ? 0 : undefined"
|
||||||
@@ -33,25 +36,37 @@ const labels: Record<TaskStatus, string> = {
|
|||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 0.75rem;
|
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;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.025em;
|
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 {
|
.status-todo {
|
||||||
background: color-mix(in srgb, var(--fs-status-todo-bg) 78%, var(--fs-status-todo) 22%);
|
background: var(--fs-status-todo-bg);
|
||||||
color: color-mix(in srgb, var(--fs-status-todo) 85%, #000 15%);
|
color: var(--fs-status-todo-fg);
|
||||||
}
|
}
|
||||||
.status-in_progress {
|
.status-in_progress {
|
||||||
background: color-mix(in srgb, var(--fs-status-in-progress-bg) 78%, var(--fs-status-in-progress) 22%);
|
background: var(--fs-status-in-progress-bg);
|
||||||
color: color-mix(in srgb, var(--fs-status-in-progress) 85%, #000 15%);
|
color: var(--fs-status-in-progress-fg);
|
||||||
}
|
}
|
||||||
.status-done {
|
.status-done {
|
||||||
background: color-mix(in srgb, var(--fs-status-done-bg) 78%, var(--fs-status-done) 22%);
|
background: var(--fs-status-done-bg);
|
||||||
color: color-mix(in srgb, var(--fs-status-done) 85%, #000 15%);
|
color: var(--fs-status-done-fg);
|
||||||
}
|
}
|
||||||
.status-cancelled {
|
.status-cancelled {
|
||||||
background: color-mix(in srgb, var(--fs-surface-raised) 78%, var(--fs-text-tertiary) 22%);
|
background: var(--fs-status-todo-bg);
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--fs-status-cancelled-fg);
|
||||||
|
}
|
||||||
|
.compact {
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: normal;
|
||||||
}
|
}
|
||||||
.clickable {
|
.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -554,8 +554,8 @@ async function confirmDelete() {
|
|||||||
/* The two bases must never look alike — one is mechanical, the other is the
|
/* The two bases must never look alike — one is mechanical, the other is the
|
||||||
reviewer's judgment, and that difference is the whole decision. */
|
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 { 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--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); }
|
.area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium-fg); }
|
||||||
|
|
||||||
.area-offer {
|
.area-offer {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -679,7 +679,7 @@ async function confirmDelete() {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, 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;
|
border-radius: 999px;
|
||||||
padding: 0.05rem 0.45rem;
|
padding: 0.05rem 0.45rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -689,7 +689,7 @@ async function confirmDelete() {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
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);
|
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.05rem 0.45rem;
|
padding: 0.05rem 0.45rem;
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ function focusInput() {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||||
border: 1px solid var(--fs-accent);
|
border: 1px solid var(--fs-accent);
|
||||||
color: var(--fs-accent);
|
color: var(--fs-accent-fg);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
white-space: nowrap;
|
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 { ref, computed } from "vue";
|
||||||
import { apiGet } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
import DiffView from "@/components/DiffView.vue";
|
import DiffView from "@/components/DiffView.vue";
|
||||||
import type { DiffLine } from "@/composables/useAssist";
|
import { computeDiff, type DiffLine } from "@/utils/diff";
|
||||||
|
|
||||||
interface NoteVersion {
|
interface NoteVersion {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -31,25 +31,7 @@ const loadingDetail = ref(false);
|
|||||||
|
|
||||||
const diff = computed<DiffLine[]>(() => {
|
const diff = computed<DiffLine[]>(() => {
|
||||||
if (!selectedVersion.value?.body) return [];
|
if (!selectedVersion.value?.body) return [];
|
||||||
const aLines = props.currentBody.split("\n");
|
return computeDiff(props.currentBody, selectedVersion.value.body);
|
||||||
const bLines = selectedVersion.value.body.split("\n");
|
|
||||||
const m = aLines.length, n = bLines.length;
|
|
||||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
||||||
for (let i = m - 1; i >= 0; i--)
|
|
||||||
for (let j = n - 1; j >= 0; j--)
|
|
||||||
dp[i][j] = aLines[i] === bLines[j]
|
|
||||||
? dp[i + 1][j + 1] + 1
|
|
||||||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
||||||
const result: DiffLine[] = [];
|
|
||||||
let i = 0, j = 0;
|
|
||||||
while (i < m && j < n) {
|
|
||||||
if (aLines[i] === bLines[j]) { result.push({ type: "equal", text: aLines[i++] }); j++; }
|
|
||||||
else if (dp[i + 1][j] >= dp[i][j + 1]) result.push({ type: "delete", text: aLines[i++] });
|
|
||||||
else result.push({ type: "insert", text: bLines[j++] });
|
|
||||||
}
|
|
||||||
while (i < m) result.push({ type: "delete", text: aLines[i++] });
|
|
||||||
while (j < n) result.push({ type: "insert", text: bLines[j++] });
|
|
||||||
return result;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
|
|||||||
@@ -548,7 +548,7 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
|
|
||||||
.note-tag-pill {
|
.note-tag-pill {
|
||||||
font-size: 0.58rem;
|
font-size: 0.58rem;
|
||||||
color: var(--fs-accent);
|
color: var(--fs-accent-fg);
|
||||||
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0 0.3rem;
|
padding: 0 0.3rem;
|
||||||
@@ -645,7 +645,7 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
.btn-tag-suggestion.applied {
|
.btn-tag-suggestion.applied {
|
||||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--fs-accent);
|
||||||
color: var(--fs-accent);
|
color: var(--fs-accent-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-suggest-strip {
|
.link-suggest-strip {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { RouterLink } from "vue-router";
|
|||||||
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||||
|
import KindBadge from "@/components/KindBadge.vue";
|
||||||
|
import type { TaskKind } from "@/types/note";
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
import { Trash2, X } from "lucide-vue-next";
|
import { Trash2, X } from "lucide-vue-next";
|
||||||
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
||||||
@@ -28,6 +30,7 @@ interface Task {
|
|||||||
due_date: string | null;
|
due_date: string | null;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
|
task_kind?: TaskKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tasks = ref<Task[]>([]);
|
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>
|
<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 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>
|
<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 v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
||||||
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
||||||
</li>
|
</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>
|
<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 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>
|
<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 v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
||||||
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -281,7 +286,7 @@ defineExpose({ reload: loadAll });
|
|||||||
<div v-if="activeTask" class="task-detail">
|
<div v-if="activeTask" class="task-detail">
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-text btn-edit-task" title="Open full editor">Edit ↗</RouterLink>
|
<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("_", " ") }}
|
{{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }}
|
||||||
</span>
|
</span>
|
||||||
<template v-if="deleteConfirmPending">
|
<template v-if="deleteConfirmPending">
|
||||||
@@ -419,8 +424,8 @@ defineExpose({ reload: loadAll });
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent); }
|
.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); }
|
.ms-status-completed { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success-fg); }
|
||||||
|
|
||||||
.task-items {
|
.task-items {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -496,7 +501,10 @@ defineExpose({ reload: loadAll });
|
|||||||
flex-shrink: 0;
|
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;
|
padding: 0.2rem 0.55rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -508,8 +516,8 @@ defineExpose({ reload: loadAll });
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
margin-left: auto;
|
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-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-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-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 { margin-left: 0.25rem; }
|
||||||
.btn-edit-task:hover { text-decoration: underline; }
|
.btn-edit-task:hover { text-decoration: underline; }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, ref, watch, onMounted } from "vue";
|
|||||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||||
import type { RuleTier } from "@/api/rulebooks";
|
import type { RuleTier } from "@/api/rulebooks";
|
||||||
|
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
||||||
|
|
||||||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||||||
const emit = defineEmits<{ close: [] }>();
|
const emit = defineEmits<{ close: [] }>();
|
||||||
@@ -256,6 +257,17 @@ watch(() => props.ruleId, load);
|
|||||||
How to apply
|
How to apply
|
||||||
<textarea v-model="howToApply" rows="4" placeholder="When / where this kicks in." />
|
<textarea v-model="howToApply" rows="4" placeholder="When / where this kicks in." />
|
||||||
</label>
|
</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>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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">
|
<script setup lang="ts">
|
||||||
import type { RuleHeader } from "@/api/rulebooks";
|
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[] }>();
|
defineProps<{ topicId: number; rules: RuleHeader[] }>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -28,6 +39,7 @@ const emit = defineEmits<{
|
|||||||
? 'Asserts a fact nobody has confirmed yet'
|
? 'Asserts a fact nobody has confirmed yet'
|
||||||
: `Check last passed ${r.last_verified}`"
|
: `Check last passed ${r.last_verified}`"
|
||||||
>{{ r.last_verified === "never" ? "unverified" : `checked ${r.last_verified}` }}</span>
|
>{{ r.last_verified === "never" ? "unverified" : `checked ${r.last_verified}` }}</span>
|
||||||
|
<UsageBadge :usage="r.usage" :dead-weight-advice="RULE_DEAD_WEIGHT" />
|
||||||
</div>
|
</div>
|
||||||
<div class="statement">{{ r.statement }}</div>
|
<div class="statement">{{ r.statement }}</div>
|
||||||
<div v-if="r.when_to_apply || r.updated_at" class="meta">
|
<div v-if="r.when_to_apply || r.updated_at" class="meta">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ref, computed, watch, type Ref } from "vue";
|
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 { apiPost, apiPut, apiDelete, apiSSEStream, type SSEStreamHandle } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import {
|
import {
|
||||||
@@ -9,17 +10,16 @@ import {
|
|||||||
export type AssistState = "idle" | "streaming" | "review";
|
export type AssistState = "idle" | "streaming" | "review";
|
||||||
export type ScopeMode = "document" | "section";
|
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 {
|
export interface AssistTarget {
|
||||||
text: string;
|
text: string;
|
||||||
startOffset: number;
|
startOffset: number;
|
||||||
endOffset: number;
|
endOffset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiffLine {
|
|
||||||
type: 'equal' | 'delete' | 'insert';
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NoteDraft {
|
export interface NoteDraft {
|
||||||
id: number;
|
id: number;
|
||||||
note_id: number;
|
note_id: number;
|
||||||
@@ -31,27 +31,6 @@ export interface NoteDraft {
|
|||||||
updated_at: string;
|
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>) {
|
export function useAssist(body: Ref<string>, noteId?: Ref<number | null>, projectId?: Ref<number | null>) {
|
||||||
const toast = useToastStore();
|
const toast = useToastStore();
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
project_id?: number | null;
|
project_id?: number | null;
|
||||||
milestone_id?: number | null;
|
milestone_id?: number | null;
|
||||||
note_type?: string;
|
note_type?: string;
|
||||||
|
verify_with?: string;
|
||||||
|
expires_when?: string;
|
||||||
}): Promise<Note> {
|
}): Promise<Note> {
|
||||||
try {
|
try {
|
||||||
return await apiPost<Note>("/api/notes", data);
|
return await apiPost<Note>("/api/notes", data);
|
||||||
@@ -42,7 +44,11 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
|
|
||||||
async function updateNote(
|
async function updateNote(
|
||||||
id: number,
|
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> {
|
): Promise<Note> {
|
||||||
try {
|
try {
|
||||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ export interface Note {
|
|||||||
is_task: boolean;
|
is_task: boolean;
|
||||||
note_type: NoteType;
|
note_type: NoteType;
|
||||||
task_kind?: TaskKind;
|
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[];
|
systems?: System[];
|
||||||
arose_from_id?: number | null;
|
arose_from_id?: number | null;
|
||||||
created_at: string;
|
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">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
import { apiGet } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
|
import KindBadge from "@/components/KindBadge.vue";
|
||||||
|
import type { TaskKind } from "@/types/note";
|
||||||
import { relativeTime } from "@/composables/useRelativeTime";
|
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 MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
|
||||||
interface ActiveProject {
|
interface ActiveProject {
|
||||||
id: number; title: string; color: string | null; last_activity: string;
|
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-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||||
<span class="task-title">{{ t.title }}</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>
|
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,6 +117,7 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
|
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||||
<span class="task-title">{{ t.title }}</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>
|
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1427,12 +1427,12 @@ textarea.input {
|
|||||||
|
|
||||||
.spec-status.violated {
|
.spec-status.violated {
|
||||||
background: var(--fs-priority-high-bg);
|
background: var(--fs-priority-high-bg);
|
||||||
color: var(--fs-priority-high);
|
color: var(--fs-priority-high-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spec-status.missing {
|
.spec-status.missing {
|
||||||
background: var(--fs-priority-medium-bg);
|
background: var(--fs-priority-medium-bg);
|
||||||
color: var(--fs-priority-medium);
|
color: var(--fs-priority-medium-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sheet {
|
.sheet {
|
||||||
|
|||||||
@@ -774,7 +774,7 @@ onUnmounted(() => {
|
|||||||
.tag-chip {
|
.tag-chip {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||||
color: var(--fs-accent);
|
color: var(--fs-accent-fg);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.1rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
}
|
}
|
||||||
@@ -928,7 +928,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.peek-linked-item:hover {
|
.peek-linked-item:hover {
|
||||||
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
|
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 {
|
.peek-linked-type {
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { apiGet } from "@/api/client";
|
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 GraphView from "@/views/GraphView.vue";
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
@@ -9,6 +14,7 @@ import {
|
|||||||
Workflow,
|
Workflow,
|
||||||
Search,
|
Search,
|
||||||
Share2,
|
Share2,
|
||||||
|
ShieldCheck,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
X,
|
X,
|
||||||
@@ -20,7 +26,7 @@ const router = useRouter();
|
|||||||
|
|
||||||
interface KnowledgeItem {
|
interface KnowledgeItem {
|
||||||
id: number;
|
id: number;
|
||||||
note_type: "note" | "task" | "process";
|
note_type: "note" | "task" | "process" | "snippet";
|
||||||
title: string;
|
title: string;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -35,12 +41,44 @@ interface KnowledgeItem {
|
|||||||
status?: string;
|
status?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
due_date?: 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 ─────────────────────────────────────────────────────────────
|
// ─── Filter state ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const activeType = ref<"" | "note" | "task" | "plan" | "process">("");
|
const activeType = ref<Facet>("");
|
||||||
const activeTag = ref("");
|
const activeTag = ref("");
|
||||||
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
@@ -66,9 +104,10 @@ const dupGroups = ref<DupGroup[]>([]);
|
|||||||
const dupSuggestion = ref("");
|
const dupSuggestion = ref("");
|
||||||
const dupLoading = ref(false);
|
const dupLoading = ref(false);
|
||||||
const dupChecked = ref(false);
|
const dupChecked = ref(false);
|
||||||
// The report follows the type filter: viewing tasks checks tasks. Anything
|
// The report follows the type filter: viewing tasks — under ANY task facet,
|
||||||
// else (all / plan / process) checks notes — the kind with the most to find.
|
// including a single kind — checks tasks. Everything else checks notes, the
|
||||||
const dupKind = computed(() => (activeType.value === "task" ? "task" : "note"));
|
// kind with the most to find.
|
||||||
|
const dupKind = computed(() => (TASK_FACETS.has(activeType.value) ? "task" : "note"));
|
||||||
|
|
||||||
async function loadDuplicates() {
|
async function loadDuplicates() {
|
||||||
dupLoading.value = true;
|
dupLoading.value = true;
|
||||||
@@ -92,8 +131,11 @@ watch(dupKind, () => { dupChecked.value = false; dupGroups.value = []; });
|
|||||||
|
|
||||||
// ─── Type counts ──────────────────────────────────────────────────────────────
|
// ─── Type counts ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface KnowledgeCounts { note: number; task: number; plan: number; process: number; total: number }
|
// One number per facet, plus the grand total. Partial because the server sends
|
||||||
const typeCounts = ref<KnowledgeCounts>({ note: 0, task: 0, plan: 0, process: 0, total: 0 });
|
// 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() {
|
async function fetchCounts() {
|
||||||
try {
|
try {
|
||||||
@@ -230,6 +272,10 @@ function onSearchInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch([activeType, sortMode], () => resetAndReobserve());
|
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(); });
|
watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
|
||||||
|
|
||||||
// ─── Today bar ────────────────────────────────────────────────────────────────
|
// ─── Today bar ────────────────────────────────────────────────────────────────
|
||||||
@@ -270,9 +316,18 @@ function isOverdue(item: KnowledgeItem): boolean {
|
|||||||
return new Date(item.due_date) < new Date(new Date().toDateString());
|
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) {
|
function openItem(item: KnowledgeItem) {
|
||||||
if (item.note_type === 'task') {
|
if (item.note_type === 'task') {
|
||||||
router.push(`/tasks/${item.id}`);
|
router.push(`/tasks/${item.id}`);
|
||||||
|
} else if (item.note_type === 'snippet') {
|
||||||
|
router.push(`/snippets/${item.id}`);
|
||||||
} else {
|
} else {
|
||||||
router.push(`/notes/${item.id}`);
|
router.push(`/notes/${item.id}`);
|
||||||
}
|
}
|
||||||
@@ -376,14 +431,14 @@ onUnmounted(() => {
|
|||||||
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
||||||
</button>
|
</button>
|
||||||
<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"
|
:key="val"
|
||||||
class="filter-btn"
|
class="filter-btn"
|
||||||
:class="{ active: activeType === val }"
|
:class="{ active: activeType === val }"
|
||||||
@click="activeType = (val as '' | 'note' | 'task' | 'plan' | 'process')"
|
@click="activeType = val"
|
||||||
>
|
>
|
||||||
<span class="filter-btn-label">{{ label }}</span>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -429,6 +484,15 @@ onUnmounted(() => {
|
|||||||
<Share2 :size="16" />
|
<Share2 :size="16" />
|
||||||
Graph
|
Graph
|
||||||
</button>
|
</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
|
<button
|
||||||
class="btn-ghost btn-compact"
|
class="btn-ghost btn-compact"
|
||||||
:disabled="dupLoading"
|
:disabled="dupLoading"
|
||||||
@@ -439,6 +503,12 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<!-- Near-duplicate report. A proposal surface only: unlike snippets
|
||||||
(which merge losslessly), notes are never merged — the right fix is
|
(which merge losslessly), notes are never merged — the right fix is
|
||||||
supersession, extraction into a reference note, or leaving parallel
|
supersession, extraction into a reference note, or leaving parallel
|
||||||
@@ -476,6 +546,12 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</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 -->
|
<!-- Loading / empty -->
|
||||||
<div v-if="loading && items.length === 0" class="knowledge-empty">Loading…</div>
|
<div v-if="loading && items.length === 0" class="knowledge-empty">Loading…</div>
|
||||||
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
|
<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-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 === '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 === 'process'">Process</span>
|
||||||
|
<span v-else-if="item.note_type === 'snippet'">Snippet</span>
|
||||||
</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-body">
|
||||||
<div class="k-card-title">{{ item.title }}</div>
|
<div class="k-card-title">{{ item.title }}</div>
|
||||||
@@ -505,14 +588,12 @@ onUnmounted(() => {
|
|||||||
<!-- Task specifics -->
|
<!-- Task specifics -->
|
||||||
<div v-if="item.note_type === 'task'" class="k-card-task">
|
<div v-if="item.note_type === 'task'" class="k-card-task">
|
||||||
<div class="task-badges">
|
<div class="task-badges">
|
||||||
<span class="status-badge" :class="`status--${item.status}`">
|
<StatusBadge v-if="item.status" :status="item.status as TaskStatus" compact />
|
||||||
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
|
<PriorityBadge
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="item.priority && item.priority !== 'none'"
|
v-if="item.priority && item.priority !== 'none'"
|
||||||
class="priority-badge"
|
:priority="item.priority as TaskPriority"
|
||||||
:class="`priority--${item.priority}`"
|
compact
|
||||||
>{{ item.priority }}</span>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
v-if="item.due_date"
|
v-if="item.due_date"
|
||||||
@@ -544,6 +625,7 @@ onUnmounted(() => {
|
|||||||
<span v-if="contentFetching" class="sentinel-loading">Loading…</span>
|
<span v-if="contentFetching" class="sentinel-loading">Loading…</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Graph panel -->
|
<!-- Graph panel -->
|
||||||
@@ -745,7 +827,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.filter-btn.active .filter-count {
|
.filter-btn.active .filter-count {
|
||||||
background: color-mix(in srgb, var(--fs-accent) 20%, transparent);
|
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; }
|
.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--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--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
|
||||||
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
|
.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-body { flex: 1; padding-right: 40px; }
|
||||||
.k-card-title {
|
.k-card-title {
|
||||||
@@ -918,7 +1008,7 @@ onUnmounted(() => {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: color-mix(in srgb, var(--fs-text-secondary) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-text-secondary) 15%, transparent);
|
||||||
color: var(--fs-text-secondary);
|
color: var(--fs-text-secondary-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Task card ──────────────────────────────────────────── */
|
/* ── Task card ──────────────────────────────────────────── */
|
||||||
@@ -932,26 +1022,7 @@ onUnmounted(() => {
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
flex-wrap: wrap;
|
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 {
|
.task-due {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ const tags = ref<string[]>([]);
|
|||||||
const projectId = ref<number | null>(null);
|
const projectId = ref<number | null>(null);
|
||||||
const milestoneId = ref<number | null>(null);
|
const milestoneId = ref<number | null>(null);
|
||||||
const noteType = ref<NoteType>("note");
|
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 dirty = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const showPreview = ref(false);
|
const showPreview = ref(false);
|
||||||
@@ -198,6 +206,41 @@ let savedTags: string[] = [];
|
|||||||
let savedProjectId: number | null = null;
|
let savedProjectId: number | null = null;
|
||||||
let savedMilestoneId: number | null = null;
|
let savedMilestoneId: number | null = null;
|
||||||
let savedNoteType: NoteType = "note";
|
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() {
|
function markDirty() {
|
||||||
dirty.value =
|
dirty.value =
|
||||||
@@ -206,7 +249,9 @@ function markDirty() {
|
|||||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||||
projectId.value !== savedProjectId ||
|
projectId.value !== savedProjectId ||
|
||||||
milestoneId.value !== savedMilestoneId ||
|
milestoneId.value !== savedMilestoneId ||
|
||||||
noteType.value !== savedNoteType;
|
noteType.value !== savedNoteType ||
|
||||||
|
verifyWith.value !== savedVerifyWith ||
|
||||||
|
expiresWhen.value !== savedExpiresWhen;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBodyUpdate(newVal: string) {
|
function onBodyUpdate(newVal: string) {
|
||||||
@@ -224,12 +269,10 @@ onMounted(async () => {
|
|||||||
projectId.value = store.currentNote.project_id ?? null;
|
projectId.value = store.currentNote.project_id ?? null;
|
||||||
milestoneId.value = store.currentNote.milestone_id ?? null;
|
milestoneId.value = store.currentNote.milestone_id ?? null;
|
||||||
noteType.value = (store.currentNote.note_type as NoteType) || "note";
|
noteType.value = (store.currentNote.note_type as NoteType) || "note";
|
||||||
savedTitle = title.value;
|
verifyWith.value = store.currentNote.verify_with || "";
|
||||||
savedBody = body.value;
|
expiresWhen.value = store.currentNote.expires_when || "";
|
||||||
savedTags = [...tags.value];
|
verifiedAt.value = store.currentNote.verified_at ?? null;
|
||||||
savedProjectId = projectId.value;
|
snapshot();
|
||||||
savedMilestoneId = milestoneId.value;
|
|
||||||
savedNoteType = noteType.value;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// New note: read type from query param
|
// New note: read type from query param
|
||||||
@@ -260,31 +303,11 @@ async function save() {
|
|||||||
const finalBody = body.value;
|
const finalBody = body.value;
|
||||||
try {
|
try {
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
await store.updateNote(noteId.value!, {
|
await store.updateNote(noteId.value!, { ...payload(), body: finalBody });
|
||||||
title: title.value,
|
snapshot();
|
||||||
body: finalBody,
|
|
||||||
tags: tags.value,
|
|
||||||
project_id: projectId.value,
|
|
||||||
milestone_id: milestoneId.value,
|
|
||||||
note_type: noteType.value,
|
|
||||||
});
|
|
||||||
savedTitle = title.value;
|
|
||||||
savedBody = body.value;
|
|
||||||
savedTags = [...tags.value];
|
|
||||||
savedProjectId = projectId.value;
|
|
||||||
savedMilestoneId = milestoneId.value;
|
|
||||||
savedNoteType = noteType.value;
|
|
||||||
dirty.value = false;
|
|
||||||
toast.show("Note saved");
|
toast.show("Note saved");
|
||||||
} else {
|
} else {
|
||||||
const note = await store.createNote({
|
const note = await store.createNote({ ...payload(), body: finalBody });
|
||||||
title: title.value,
|
|
||||||
body: finalBody,
|
|
||||||
tags: tags.value,
|
|
||||||
project_id: projectId.value,
|
|
||||||
milestone_id: milestoneId.value,
|
|
||||||
note_type: noteType.value,
|
|
||||||
});
|
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Note created");
|
toast.show("Note created");
|
||||||
router.push(`/notes/${note.id}`);
|
router.push(`/notes/${note.id}`);
|
||||||
@@ -321,18 +344,8 @@ async function doAutoSave() {
|
|||||||
saving.value = true;
|
saving.value = true;
|
||||||
const finalBody = body.value;
|
const finalBody = body.value;
|
||||||
try {
|
try {
|
||||||
await store.updateNote(noteId.value!, {
|
await store.updateNote(noteId.value!, { ...payload(), body: finalBody });
|
||||||
title: title.value, body: finalBody, tags: tags.value,
|
snapshot();
|
||||||
project_id: projectId.value, milestone_id: milestoneId.value,
|
|
||||||
note_type: noteType.value,
|
|
||||||
});
|
|
||||||
savedTitle = title.value;
|
|
||||||
savedBody = body.value;
|
|
||||||
savedTags = [...tags.value];
|
|
||||||
savedProjectId = projectId.value;
|
|
||||||
savedMilestoneId = milestoneId.value;
|
|
||||||
savedNoteType = noteType.value;
|
|
||||||
dirty.value = false;
|
|
||||||
toast.show("Auto-saved");
|
toast.show("Auto-saved");
|
||||||
} catch {
|
} catch {
|
||||||
// Silent
|
// Silent
|
||||||
@@ -496,6 +509,41 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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 -->
|
<!-- Link Suggestions -->
|
||||||
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
|
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
|
||||||
<div class="sb-label-row">
|
<div class="sb-label-row">
|
||||||
@@ -678,7 +726,7 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sb-select, .sb-input {
|
.sb-select, .sb-input, .sb-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
@@ -690,9 +738,27 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s;
|
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);
|
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 Suggestions */
|
||||||
.link-suggest-field { gap: 0.4rem; }
|
.link-suggest-field { gap: 0.4rem; }
|
||||||
|
|||||||
@@ -416,7 +416,7 @@ async function convertToTask() {
|
|||||||
}
|
}
|
||||||
.badge-note {
|
.badge-note {
|
||||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
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);
|
border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
|
||||||
}
|
}
|
||||||
.badge-task {
|
.badge-task {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
|
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
|
||||||
|
import ProjectStatusBadge from "@/components/ProjectStatusBadge.vue";
|
||||||
import { emptyChoices, type InceptionChoices } from "@/api/inception";
|
import { emptyChoices, type InceptionChoices } from "@/api/inception";
|
||||||
import InceptionCard from "@/components/InceptionCard.vue";
|
import InceptionCard from "@/components/InceptionCard.vue";
|
||||||
import { useToastStore } from "@/stores/toast";
|
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 {
|
function truncate(text: string | null, max = 120): string {
|
||||||
if (!text) return "";
|
if (!text) return "";
|
||||||
@@ -210,9 +204,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
>
|
>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="project-title">{{ project.title }}</span>
|
<span class="project-title">{{ project.title }}</span>
|
||||||
<span
|
<ProjectStatusBadge :status="project.status" />
|
||||||
:class="['status-badge', `status-${project.status}`]"
|
|
||||||
>{{ statusLabel(project.status) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-if="project.goal" class="project-goal">
|
<p v-if="project.goal" class="project-goal">
|
||||||
<span class="field-label">Goal:</span> {{ truncate(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;
|
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 {
|
.project-goal {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { useTasksStore } from "@/stores/tasks";
|
|||||||
import { relativeTime } from "@/composables/useRelativeTime";
|
import { relativeTime } from "@/composables/useRelativeTime";
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
import ShareDialog from "@/components/ShareDialog.vue";
|
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 ProjectDesignTab from "@/components/ProjectDesignTab.vue";
|
||||||
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
||||||
import SystemsSection from "@/components/SystemsSection.vue";
|
import SystemsSection from "@/components/SystemsSection.vue";
|
||||||
@@ -74,6 +77,7 @@ interface NoteItem {
|
|||||||
due_date?: string | null;
|
due_date?: string | null;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
milestone_id?: number | null;
|
milestone_id?: number | null;
|
||||||
|
task_kind?: TaskKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -693,9 +697,7 @@ async function confirmDelete() {
|
|||||||
<div class="project-header">
|
<div class="project-header">
|
||||||
<div class="title-row">
|
<div class="title-row">
|
||||||
<input v-model="editTitle" type="text" class="project-title-input" placeholder="Project title" />
|
<input v-model="editTitle" type="text" class="project-title-input" placeholder="Project title" />
|
||||||
<span :class="['status-badge', `status-${project.status}`]">
|
<ProjectStatusBadge :status="project.status" />
|
||||||
{{ project.status.charAt(0).toUpperCase() + project.status.slice(1) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-if="project.goal" class="project-goal">{{ project.goal }}</p>
|
<p v-if="project.goal" class="project-goal">{{ project.goal }}</p>
|
||||||
<p v-if="project.summary?.last_activity" class="project-activity">
|
<p v-if="project.summary?.last_activity" class="project-activity">
|
||||||
@@ -1046,6 +1048,7 @@ async function confirmDelete() {
|
|||||||
:class="['task-card', `pri-${task.priority || 'none'}`]"
|
:class="['task-card', `pri-${task.priority || 'none'}`]"
|
||||||
>
|
>
|
||||||
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
||||||
|
<KindBadge :kind="task.task_kind" />
|
||||||
<div class="task-card-footer">
|
<div class="task-card-footer">
|
||||||
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
|
<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>
|
<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'}`]"
|
:class="['task-card', `pri-${task.priority || 'none'}`]"
|
||||||
>
|
>
|
||||||
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
||||||
|
<KindBadge :kind="task.task_kind" />
|
||||||
<div class="task-card-footer">
|
<div class="task-card-footer">
|
||||||
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
|
<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>
|
<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"
|
class="task-card task-card-done"
|
||||||
>
|
>
|
||||||
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
||||||
|
<KindBadge :kind="task.task_kind" />
|
||||||
<div v-if="task.due_date" class="task-meta">
|
<div v-if="task.due_date" class="task-meta">
|
||||||
<span class="due-date">{{ task.due_date }}</span>
|
<span class="due-date">{{ task.due_date }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1234,19 +1239,6 @@ async function confirmDelete() {
|
|||||||
.project-title-input:focus { border-bottom-color: var(--fs-accent); }
|
.project-title-input:focus { border-bottom-color: var(--fs-accent); }
|
||||||
.project-title-input::placeholder { color: var(--fs-text-tertiary); font-weight: 400; }
|
.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 {
|
.project-goal {
|
||||||
font-size: 1rem;
|
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-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-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-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); border-color: color-mix(in srgb, var(--fs-accent) 22%, 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 ───────────────────────────── */
|
/* ── Pattern-library coverage card ───────────────────────────── */
|
||||||
.coverage-card {
|
.coverage-card {
|
||||||
@@ -1479,7 +1471,7 @@ async function confirmDelete() {
|
|||||||
.tab-btn.active .tab-count {
|
.tab-btn.active .tab-count {
|
||||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||||
border-color: color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
border-color: color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||||
color: var(--fs-accent);
|
color: var(--fs-accent-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tasks view ──────────────────────────────────────────────── */
|
/* ── Tasks view ──────────────────────────────────────────────── */
|
||||||
@@ -1715,7 +1707,7 @@ async function confirmDelete() {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
margin-left: auto;
|
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; }
|
.kanban-cards { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { User } from "@/types/auth";
|
|||||||
import PaginationBar from "@/components/PaginationBar.vue";
|
import PaginationBar from "@/components/PaginationBar.vue";
|
||||||
import TagInput from "@/components/TagInput.vue";
|
import TagInput from "@/components/TagInput.vue";
|
||||||
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
||||||
|
import { fetchVersion, type VersionPayload } from "@/api/version";
|
||||||
|
|
||||||
const store = useSettingsStore();
|
const store = useSettingsStore();
|
||||||
const authStore = useAuthStore();
|
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
|
// 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.
|
// unrelated code through (#2223). Shares top-k, not the threshold.
|
||||||
const kbWritePathThreshold = ref("0.68");
|
const kbWritePathThreshold = ref("0.68");
|
||||||
|
const kbRuleHintThreshold = ref("0.72");
|
||||||
// Near-duplicate report floors, one per record kind (services/dedup.py).
|
// 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
|
// 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
|
// 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
|
// Same `|| default` reasoning: falling back to 0 would surface every
|
||||||
// snippet in the corpus on every edit, which is the failure this knob fixes.
|
// 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));
|
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);
|
kbInjectThreshold.value = String(t);
|
||||||
kbInjectTopK.value = String(k);
|
kbInjectTopK.value = String(k);
|
||||||
kbDupThresholdSnippet.value = String(dupSnip);
|
kbDupThresholdSnippet.value = String(dupSnip);
|
||||||
kbDupThresholdNote.value = String(dupNote);
|
kbDupThresholdNote.value = String(dupNote);
|
||||||
kbDupThresholdTask.value = String(dupTask);
|
kbDupThresholdTask.value = String(dupTask);
|
||||||
kbWritePathThreshold.value = String(wpT);
|
kbWritePathThreshold.value = String(wpT);
|
||||||
|
kbRuleHintThreshold.value = String(rhT);
|
||||||
savingKbInject.value = true;
|
savingKbInject.value = true;
|
||||||
kbInjectSaved.value = false;
|
kbInjectSaved.value = false;
|
||||||
try {
|
try {
|
||||||
@@ -165,6 +172,10 @@ async function saveKbInject() {
|
|||||||
// measurements that split them.
|
// measurements that split them.
|
||||||
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
|
kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false',
|
||||||
kb_writepath_threshold: String(wpT),
|
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_snippet: String(dupSnip),
|
||||||
kb_duplicate_threshold_note: String(dupNote),
|
kb_duplicate_threshold_note: String(dupNote),
|
||||||
kb_duplicate_threshold_task: String(dupTask),
|
kb_duplicate_threshold_task: String(dupTask),
|
||||||
@@ -187,7 +198,47 @@ const changingPassword = ref(false);
|
|||||||
const invalidatingSessions = ref(false);
|
const invalidatingSessions = ref(false);
|
||||||
const exporting = ref(false);
|
const exporting = ref(false);
|
||||||
const restoring = 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);
|
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
// 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 === "logs") loadLogsPanel();
|
||||||
else if (tab === "groups") loadGroupsPanel();
|
else if (tab === "groups") loadGroupsPanel();
|
||||||
else if (tab === "areas") canonStore.fetchCatalog(true);
|
else if (tab === "areas") canonStore.fetchCatalog(true);
|
||||||
|
else if (tab === "config" && !versionInfo.value) loadVersionPanel();
|
||||||
}
|
}
|
||||||
if (tab === "apikeys") { fetchApiKeys(); }
|
if (tab === "apikeys") { fetchApiKeys(); }
|
||||||
}
|
}
|
||||||
@@ -554,10 +606,6 @@ function toggleProfileWorkDay(day: string) {
|
|||||||
function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) }
|
function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) }
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
|
||||||
const v = await apiGet<{ version: string }>('/api/version')
|
|
||||||
appVersion.value = v.version
|
|
||||||
} catch { /* non-critical */ }
|
|
||||||
await store.fetchSettings();
|
await store.fetchSettings();
|
||||||
newEmail.value = authStore.user?.email ?? "";
|
newEmail.value = authStore.user?.email ?? "";
|
||||||
|
|
||||||
@@ -573,6 +621,9 @@ onMounted(async () => {
|
|||||||
kbInjectTopK.value = allSettings.kb_autoinject_top_k;
|
kbInjectTopK.value = allSettings.kb_autoinject_top_k;
|
||||||
}
|
}
|
||||||
kbWritePathEnabled.value = allSettings.kb_writepath_enabled !== "false";
|
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) {
|
if (allSettings.kb_writepath_threshold !== undefined) {
|
||||||
kbWritePathThreshold.value = allSettings.kb_writepath_threshold;
|
kbWritePathThreshold.value = allSettings.kb_writepath_threshold;
|
||||||
}
|
}
|
||||||
@@ -727,7 +778,7 @@ async function exportData(scope: "user" | "full") {
|
|||||||
exporting.value = true;
|
exporting.value = true;
|
||||||
try {
|
try {
|
||||||
const url = scope === "full" ? "/api/admin/backup" : "/api/admin/backup?scope=user";
|
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) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
||||||
throw new Error((body as Record<string, string>).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") {
|
async function exportNotes(format: "markdown" | "json") {
|
||||||
exportingNotes.value = true;
|
exportingNotes.value = true;
|
||||||
try {
|
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}`);
|
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const ext = format === "json" ? "json" : "zip";
|
const ext = format === "json" ? "json" : "zip";
|
||||||
@@ -981,6 +1032,7 @@ async function handleRestoreFile(event: Event) {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
signal: bulkDeadline(),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
|
||||||
@@ -1417,6 +1469,29 @@ async function deleteUser(userId: number) {
|
|||||||
location, not by resemblance.
|
location, not by resemblance.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<!-- 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
|
the project. There was a setting here that designated the system
|
||||||
this install's own interface was built from; it only ever described
|
this install's own interface was built from; it only ever described
|
||||||
@@ -2109,6 +2184,48 @@ async function deleteUser(userId: number) {
|
|||||||
<!-- ── Admin ── -->
|
<!-- ── Admin ── -->
|
||||||
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
|
<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">
|
<section class="settings-section full-width">
|
||||||
<h2>Application URL</h2>
|
<h2>Application URL</h2>
|
||||||
<p class="section-desc">
|
<p class="section-desc">
|
||||||
@@ -2732,7 +2849,7 @@ async function deleteUser(userId: number) {
|
|||||||
background: var(--fs-surface-raised);
|
background: var(--fs-surface-raised);
|
||||||
}
|
}
|
||||||
.sidebar-item.active {
|
.sidebar-item.active {
|
||||||
color: var(--fs-accent);
|
color: var(--fs-accent-fg);
|
||||||
background: color-mix(in srgb, var(--fs-accent) 8%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 8%, transparent);
|
||||||
border-left-color: var(--fs-accent);
|
border-left-color: var(--fs-accent);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -2768,6 +2885,45 @@ async function deleteUser(userId: number) {
|
|||||||
letter-spacing: 0.07em;
|
letter-spacing: 0.07em;
|
||||||
color: var(--fs-text-tertiary);
|
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 {
|
.section-desc {
|
||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -3099,7 +3255,7 @@ async function deleteUser(userId: number) {
|
|||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
}
|
}
|
||||||
.role-admin {
|
.role-admin {
|
||||||
color: var(--fs-accent);
|
color: var(--fs-accent-fg);
|
||||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||||
}
|
}
|
||||||
.role-user {
|
.role-user {
|
||||||
@@ -3179,9 +3335,9 @@ async function deleteUser(userId: number) {
|
|||||||
text-transform: uppercase; letter-spacing: 0.05em;
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
padding: 0.1rem 0.35rem; border-radius: var(--fs-radius-sm);
|
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-audit { color: var(--fs-accent-fg); 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-usage { color: var(--fs-success-fg); 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-error { color: var(--fs-error-fg); background: color-mix(in srgb, var(--fs-error) 15%, transparent); }
|
||||||
.method-tag {
|
.method-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 0.65rem; font-weight: 500; font-family: monospace;
|
font-size: 0.65rem; font-weight: 500; font-family: monospace;
|
||||||
@@ -3346,8 +3502,8 @@ async function deleteUser(userId: number) {
|
|||||||
padding: 0.15rem 0.4rem;
|
padding: 0.15rem 0.4rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.role-owner { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning); }
|
.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); }
|
.role-member { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary-fg); }
|
||||||
|
|
||||||
.members-empty {
|
.members-empty {
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--fs-text-tertiary);
|
||||||
@@ -3528,7 +3684,7 @@ async function deleteUser(userId: number) {
|
|||||||
.day-btn.active {
|
.day-btn.active {
|
||||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--fs-accent);
|
||||||
color: var(--fs-accent);
|
color: var(--fs-accent-fg);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -242,9 +242,9 @@ onMounted(async () => {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.perm-viewer { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary); }
|
.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); }
|
.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); }
|
.perm-admin { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning-fg); }
|
||||||
|
|
||||||
.empty-msg {
|
.empty-msg {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ async function confirmDelete() {
|
|||||||
font-family: var(--fs-font-mono);
|
font-family: var(--fs-font-mono);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||||
color: var(--fs-accent);
|
color: var(--fs-accent-fg);
|
||||||
padding: 0.08rem 0.35rem;
|
padding: 0.08rem 0.35rem;
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type SnippetListItem,
|
type SnippetListItem,
|
||||||
} from "@/api/snippets";
|
} from "@/api/snippets";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
import UsageBadge from "@/components/UsageBadge.vue";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const toast = useToastStore();
|
const toast = useToastStore();
|
||||||
@@ -198,23 +199,6 @@ function languageOf(tags: string[]): string {
|
|||||||
return tags.find((t) => t && t !== "snippet") ?? "";
|
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.
|
/** 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 —
|
* An expired verdict is reported as "unchecked" whatever it used to say —
|
||||||
* it was about code that is no longer in the record. */
|
* 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}.`;
|
return v.detail ? `${when}: ${what}. ${v.detail}` : `${when}: ${what}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function usageTitle(s: SnippetListItem): string {
|
/** The dead-weight nudge for a SNIPPET, passed to the shared badge. Kept here
|
||||||
const u = s.usage;
|
* rather than inside the component because the remedy is kind-specific — a
|
||||||
if (!u) return "";
|
* rule in the same position gets different advice (milestone 333 step 5). */
|
||||||
const last = u.last_pulled_at
|
const SNIPPET_DEAD_WEIGHT =
|
||||||
? `Last opened ${new Date(u.last_pulled_at).toLocaleDateString()}.`
|
"Offered repeatedly without ever being opened — consider rewriting its " +
|
||||||
: "Never opened.";
|
"“when to reach for it” so it says when, or deleting it. It takes a slot " +
|
||||||
const verdict = isDeadWeight(s)
|
"in every future auto-inject menu.";
|
||||||
? " Offered repeatedly without ever being opened — consider rewriting its" +
|
|
||||||
" “when to reach for it” so it says when, or deleting it. It takes a slot" +
|
|
||||||
" in every future auto-inject menu."
|
|
||||||
: "";
|
|
||||||
return (
|
|
||||||
`Surfaced to an agent ${u.surfaced_count}×, opened in full ` +
|
|
||||||
`${u.pull_count}×. ${last}${verdict}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -456,14 +431,7 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
<span v-if="driftBadge(s)" class="drift-tag" :title="driftTitle(s)">
|
<span v-if="driftBadge(s)" class="drift-tag" :title="driftTitle(s)">
|
||||||
{{ driftBadge(s) }}
|
{{ driftBadge(s) }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<UsageBadge :usage="s.usage" :dead-weight-advice="SNIPPET_DEAD_WEIGHT" />
|
||||||
v-if="usageBadge(s)"
|
|
||||||
class="usage-tag"
|
|
||||||
:class="{ 'usage-dead': isDeadWeight(s) }"
|
|
||||||
:title="usageTitle(s)"
|
|
||||||
>
|
|
||||||
{{ usageBadge(s) }}
|
|
||||||
</span>
|
|
||||||
<span v-if="s.shared" class="shared-tag" :title="`Shared by ${s.owner ?? 'another user'} — a suggestion, not your own record`">
|
<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" }}
|
by {{ s.owner ?? "another user" }}
|
||||||
</span>
|
</span>
|
||||||
@@ -710,7 +678,7 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||||
color: var(--fs-accent);
|
color: var(--fs-accent-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.snippet-when {
|
.snippet-when {
|
||||||
@@ -739,7 +707,7 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--fs-text-tertiary-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dup-action {
|
.dup-action {
|
||||||
@@ -754,24 +722,7 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
|
||||||
color: var(--fs-error);
|
color: var(--fs-error-fg);
|
||||||
}
|
|
||||||
|
|
||||||
.usage-tag {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
|
|
||||||
than the danger one, because the record isn't broken, just unearned. */
|
|
||||||
.usage-tag.usage-dead {
|
|
||||||
background: color-mix(in srgb, var(--fs-warning) 18%, transparent);
|
|
||||||
color: var(--fs-warning);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header + select-mode */
|
/* Header + select-mode */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"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.",
|
"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": {
|
"author": {
|
||||||
"name": "Bryan Van Deusen"
|
"name": "Bryan Van Deusen"
|
||||||
},
|
},
|
||||||
|
|||||||
+7
-2
@@ -78,8 +78,13 @@ On install you'll be asked for:
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Set a `version` bump in `.claude-plugin/plugin.json` per release so clients
|
- **Do not hand-edit `version` in `.claude-plugin/plugin.json`.** It is minted
|
||||||
pick up changes.
|
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
|
- 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
|
key; the MCP tools need **write** scope to create/update. Every hook is a GET
|
||||||
for that reason — a read key cannot POST.
|
for that reason — a read key cannot POST.
|
||||||
|
|||||||
@@ -33,6 +33,15 @@
|
|||||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_prior_art.sh\""
|
"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": [
|
"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=""
|
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}"
|
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
|
||||||
fi
|
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
|
if [ -n "$path_enc" ]; then
|
||||||
# 8s, not the pre-write hook's 5: this hook runs AFTER the tool, so it
|
# 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
|
# 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
|
reached=1
|
||||||
body=$(curl -fsS --max-time 8 \
|
body=$(curl -fsS --max-time 8 \
|
||||||
-H "Authorization: Bearer ${token}" \
|
-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
|
# 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
|
# (#2932) — shared marker with the pre-write hook, so one outage is one
|
||||||
# line however the code was written.
|
# 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)
|
event=$(cat 2>/dev/null || true)
|
||||||
source=$(printf '%s' "$event" | jq -r '.source // empty' 2>/dev/null) || source=""
|
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=""
|
out=""
|
||||||
# Append $1 to $out, separated by a horizontal rule when $out already has content.
|
# 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; }
|
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}" \
|
-H "Authorization: Bearer ${token}" \
|
||||||
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
|
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
|
||||||
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
|
[ -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."
|
[ -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
|
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()\`."
|
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
|
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
|
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.
|
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
|
- **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
|
work or start a task, `search` Scribe first; assume a related note, task, or
|
||||||
decision already exists. Concretely, reach for recall whenever a request
|
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
|
re-deriving it or opening a duplicate. When a project is in scope, pass its
|
||||||
`project_id` so results stay scoped.
|
`project_id` so results stay scoped.
|
||||||
|
|
||||||
2. **Standing rules are binding.** Load them via `list_always_on_rules()` at
|
2. **Standing rules are binding — and the ones you were handed are not all of
|
||||||
session start (see "Do this first"); treat every one as binding. Pull a
|
them.** Load the resident set via `list_always_on_rules()` at session start
|
||||||
rule's full statement with `get_rule(id)` when it's about to bite. When a
|
(see "Do this first"); treat every one as binding. Pull a rule's full
|
||||||
project is in scope, `enter_project(id)` also returns its applicable rules.
|
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
|
3. **Update over duplicate.** When recording, prefer updating an existing
|
||||||
note/rule/task over creating a new one. Search first; revise what's there.
|
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
|
not restraint. Only a record genuinely about no particular area goes
|
||||||
untagged.
|
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
|
*happened* — write it once, never rewrite it. A durable finding (how a
|
||||||
subsystem works, a measured number) lives in that System's **reference
|
subsystem works, a measured number) lives in that System's **reference
|
||||||
note** ("«System» — reference"), which you UPDATE as facts change — safe,
|
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
|
re-measurement, a reversed decision), pass the old id in `supersedes` so the
|
||||||
stale record is demoted and labelled rather than left competing.
|
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
|
## Stay inside the active project's scope
|
||||||
|
|
||||||
Once a project is in scope — you called `enter_project`, or the working repo is
|
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)
|
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:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--sheet", default="frontend/src/assets/theme.css")
|
parser.add_argument("--sheet", default="frontend/src/assets/theme.css")
|
||||||
@@ -117,6 +170,8 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
unresolved: list[tuple[pathlib.Path, str]] = []
|
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]] = []
|
superseded_hits: list[tuple[pathlib.Path, str, str]] = []
|
||||||
literal_count = 0
|
literal_count = 0
|
||||||
|
|
||||||
@@ -140,6 +195,12 @@ def main() -> int:
|
|||||||
|
|
||||||
literal_count += len(HEX_LITERAL.findall(css))
|
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:
|
if unresolved:
|
||||||
print(f"FAIL — {len(unresolved)} unresolvable var() reference(s).")
|
print(f"FAIL — {len(unresolved)} unresolvable var() reference(s).")
|
||||||
print(" These render as the fallback if given one, or as nothing at all.")
|
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(f" {path}: {literal} -> {token}")
|
||||||
print()
|
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:
|
if args.report_literals:
|
||||||
print(f"REPORT — {literal_count} raw colour literal(s) in component CSS.")
|
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 "
|
print(" Advisory: a literal is a value stated outside the system, so it "
|
||||||
"cannot follow a palette change.\n")
|
"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__":
|
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
|
install, because `plugin.json`'s version wasn't bumped and the installer
|
||||||
compares versions to decide whether to refresh its cache.
|
compares versions to decide whether to refresh its cache.
|
||||||
|
|
||||||
The rule for the second one was already written down and was still missed. A
|
Both were fixed. The second was fixed TWICE — once by bumping the number, and
|
||||||
written rule that depends on being remembered during a long session is not a
|
then properly, by removing the class it came from: `plugin.json`'s version is
|
||||||
control; this 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
|
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
|
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.
|
whole file exists to prevent.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 scripts/check_plugin.py # all checks
|
python3 scripts/check_plugin.py # all checks
|
||||||
python3 scripts/check_plugin.py --no-version # skip the bump check
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -43,16 +61,90 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
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"
|
PLUGIN_DIR = ROOT / "plugin"
|
||||||
HOOKS_DIR = PLUGIN_DIR / "hooks"
|
HOOKS_DIR = PLUGIN_DIR / "hooks"
|
||||||
MANIFEST = PLUGIN_DIR / ".claude-plugin" / "plugin.json"
|
MANIFEST = PLUGIN_DIR / ".claude-plugin" / "plugin.json"
|
||||||
|
|
||||||
# Paths whose contents reach an install. Keep in step with the workflow's
|
# ── What ships, and what decides what it says about itself ─────────────────
|
||||||
# `paths:` filter — a path that ships but isn't checked here is the gap again.
|
#
|
||||||
SHIPPED = ("plugin", ".claude-plugin")
|
# 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] = []
|
failures: list[str] = []
|
||||||
|
|
||||||
@@ -143,8 +235,6 @@ def check_patterns() -> None:
|
|||||||
ok(f"{rel}: no known-bad patterns")
|
ok(f"{rel}: no known-bad patterns")
|
||||||
|
|
||||||
|
|
||||||
# --- the version bump ------------------------------------------------------
|
|
||||||
|
|
||||||
# --- shellcheck ------------------------------------------------------------
|
# --- shellcheck ------------------------------------------------------------
|
||||||
|
|
||||||
def check_shellcheck() -> None:
|
def check_shellcheck() -> None:
|
||||||
@@ -215,6 +305,16 @@ SMOKE_EVENTS: dict[str, str] = {
|
|||||||
"tool_input": {"file_path": "src/x.py",
|
"tool_input": {"file_path": "src/x.py",
|
||||||
"new_string": f"def {_ABSENT_SYM}():\n pass\n"}}
|
"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_sync_processes.sh": json.dumps({"source": "startup"}),
|
||||||
"scribe_session_context.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
|
# 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()
|
return proc.returncode, (proc.stdout or proc.stderr).strip()
|
||||||
|
|
||||||
|
|
||||||
def manifest_version(ref: str | None = None) -> str | None:
|
def manifest_text(ref: str | None = None) -> str | None:
|
||||||
"""The manifest version at `ref`, or in the working tree when ref is 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:
|
if ref is None:
|
||||||
try:
|
try:
|
||||||
return json.loads(MANIFEST.read_text()).get("version")
|
return MANIFEST.read_text()
|
||||||
except Exception:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
rel = MANIFEST.relative_to(ROOT).as_posix()
|
rel = MANIFEST.relative_to(ROOT).as_posix()
|
||||||
code, out = _git("show", f"{ref}:{rel}")
|
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
|
return None
|
||||||
try:
|
try:
|
||||||
return json.loads(out).get("version")
|
return json.loads(text).get("version")
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def check_version_bump(base: str = "origin/main") -> None:
|
# Distinct from None, which is a legitimate "this manifest does not exist".
|
||||||
"""If shipped plugin content differs from `base`, the version must too.
|
_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
|
def manifest_differs_beyond_version(a: str | None, b: str | None) -> bool:
|
||||||
actually matters is that whatever reaches an install carries a version the
|
"""Do two `plugin.json` texts differ in anything OTHER than `version`?
|
||||||
installer can tell apart from the one already cached. One bump per batch,
|
|
||||||
which is also how a human would do it.
|
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)
|
code, _ = _git("rev-parse", "--verify", base)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
# Do NOT pass silently — a check that quietly no-ops is how this class
|
# Do NOT pass silently — a check that quietly no-ops is how this class
|
||||||
# of bug survives in the first place.
|
# of bug survives in the first place.
|
||||||
fail(
|
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"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"origin/main` is enough, since this diffs two trees and needs no "
|
||||||
f"common ancestor — or pass --no-version deliberately."
|
f"common ancestor — or pass --no-version deliberately."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
code, changed = _git("diff", "--name-only", base, "--", *SHIPPED)
|
here = manifest_version()
|
||||||
if code != 0:
|
|
||||||
fail(f"git diff against {base} failed: {changed}")
|
|
||||||
return
|
|
||||||
if not changed.strip():
|
|
||||||
ok(f"no shipped plugin changes against {base} — version bump not required")
|
|
||||||
return
|
|
||||||
|
|
||||||
here, there = manifest_version(), manifest_version(base)
|
|
||||||
if here is None:
|
if here is None:
|
||||||
fail(f"could not read a version from {MANIFEST.relative_to(ROOT)}")
|
fail(f"could not read a version from {MANIFEST.relative_to(ROOT)}")
|
||||||
return
|
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:
|
if there is None:
|
||||||
ok(f"no manifest on {base} — treating as a new plugin (version {here})")
|
ok(f"no manifest on {base} — treating as a new plugin (version {here})")
|
||||||
return
|
return
|
||||||
if here == there:
|
|
||||||
files = "\n ".join(changed.splitlines())
|
if changed and here == there:
|
||||||
|
files = "\n ".join(paths)
|
||||||
fail(
|
fail(
|
||||||
f"plugin content changed but the manifest version is still {here}.\n"
|
f"plugin content changed but the version is still {here}.\n"
|
||||||
f" The installer compares versions to decide whether to refresh "
|
f" The installer decides whether to refresh its cache by "
|
||||||
f"its cache, so an unchanged version means these edits reach the repo "
|
f"comparing this string, so an unchanged version means these edits "
|
||||||
f"and stop there — the marketplace clone updates, the cache that "
|
f"reach the repo and stop there — the marketplace clone updates, the "
|
||||||
f"actually executes does not (issue #2209).\n"
|
f"cache that actually executes does not (#2209, #1040, #2220).\n"
|
||||||
f" Bump `version` in {MANIFEST.relative_to(ROOT)}.\n"
|
f" Run `make mint-plugin`.\n"
|
||||||
f" Changed:\n {files}"
|
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:
|
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:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--no-version", action="store_true",
|
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",
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if not HOOKS_DIR.is_dir():
|
if not HOOKS_DIR.is_dir():
|
||||||
@@ -478,7 +711,7 @@ def main() -> int:
|
|||||||
check_local_prior_art_needs_no_instance()
|
check_local_prior_art_needs_no_instance()
|
||||||
check_session_context_reports_its_version()
|
check_session_context_reports_its_version()
|
||||||
if not args.no_version:
|
if not args.no_version:
|
||||||
check_version_bump(args.base)
|
check_version_is_minted(args.base)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if failures:
|
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
|
# duplicate gate, the untagged-record systems_hint) act in-band in tool
|
||||||
# responses, at the moment they apply.
|
# responses, at the moment they apply.
|
||||||
# Grow one of those, not this block.
|
# 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 = """
|
_INSTRUCTIONS = """
|
||||||
Scribe is the operator's self-hosted second brain and system of record — and
|
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
|
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.
|
active project_id to stay in scope.
|
||||||
- WHERE work happens: Systems. Tag records with system_ids as you write;
|
- WHERE work happens: Systems. Tag records with system_ids as you write;
|
||||||
create_system when the area is unmodelled.
|
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 /
|
- UI: the project's design system is binding — resolve_design_system /
|
||||||
get_design_system_stylesheet before hand-writing a value.
|
get_design_system_stylesheet before hand-writing a value.
|
||||||
- REUSE: search snippets before writing a helper; record what you build with
|
- REUSE: search snippets before writing a helper; record what you build with
|
||||||
create_snippet; classify shapes against canon (classify_shapes) — a
|
create_snippet; classify shapes against canon (classify_shapes) — a
|
||||||
consumer map is rows, never prose. Processes are saved procedures (follow
|
consumer map is rows, never prose.
|
||||||
verbatim). Deletes are trash-recoverable.
|
|
||||||
|
|
||||||
A task is a note with status (*_note vs *_task tools).
|
A task is a note with status (*_note vs *_task tools).
|
||||||
Creates are duplicate-gated: a near-match BLOCKS and returns the existing
|
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
|
# prefix, so the completeness test below cannot derive it — the same
|
||||||
# reason `enter_project` is spelled out above.
|
# reason `enter_project` is spelled out above.
|
||||||
"retrieval_telemetry",
|
"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
|
# 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 {
|
return {
|
||||||
"milestone": out,
|
"milestone": out,
|
||||||
"steps": [t.to_dict() for t in steps],
|
"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.
|
"""Move a milestone to the trash (recoverable). Its tasks go with it as one batch.
|
||||||
Restore via restore(batch_id)."""
|
Restore via restore(batch_id)."""
|
||||||
uid = current_user_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)
|
batch = await trash_svc.delete(uid, "milestone", milestone_id)
|
||||||
if batch is None:
|
if batch is None:
|
||||||
raise ValueError(f"milestone {milestone_id} not found")
|
raise ValueError(f"milestone {milestone_id} not found")
|
||||||
return {"deleted_batch_id": batch,
|
return {"deleted": milestone_id, "title": title, "deleted_batch_id": batch,
|
||||||
"message": f"Milestone {milestone_id} + its tasks moved to trash. Restore with restore('{batch}')."}
|
"message": f'Milestone {milestone_id} ("{title}") and its tasks '
|
||||||
|
f"moved to trash. Restore with restore('{batch}')."}
|
||||||
|
|
||||||
|
|
||||||
def register(mcp) -> None:
|
def register(mcp) -> None:
|
||||||
|
|||||||
@@ -103,10 +103,23 @@ async def create_note(
|
|||||||
project_id: int = 0,
|
project_id: int = 0,
|
||||||
system_ids: list[int] | None = None,
|
system_ids: list[int] | None = None,
|
||||||
supersedes: list[int] | None = None,
|
supersedes: list[int] | None = None,
|
||||||
|
verify_with: str = "",
|
||||||
|
expires_when: str = "",
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new note in Scribe.
|
"""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:
|
Args:
|
||||||
title: Note title (required).
|
title: Note title (required).
|
||||||
body: Markdown content. Supports [[wikilinks]] to other notes by title.
|
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
|
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
|
verdict: it never says the older note was wrong, only that it is no
|
||||||
longer the current answer.
|
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
|
force: Bypass the near-duplicate gate. By default, if a title- or
|
||||||
meaning-similar note already exists in the same project, creation is
|
meaning-similar note already exists in the same project, creation is
|
||||||
BLOCKED and the existing note's id is returned so you update it
|
BLOCKED and the existing note's id is returned so you update it
|
||||||
@@ -150,6 +190,8 @@ async def create_note(
|
|||||||
body=body,
|
body=body,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
project_id=project_id or None,
|
project_id=project_id or None,
|
||||||
|
verify_with=verify_with,
|
||||||
|
expires_when=expires_when,
|
||||||
)
|
)
|
||||||
if system_ids:
|
if system_ids:
|
||||||
await systems_svc.set_record_systems(uid, note.id, 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,
|
project_id: int = 0,
|
||||||
system_ids: list[int] | None = None,
|
system_ids: list[int] | None = None,
|
||||||
supersedes: list[int] | None = None,
|
supersedes: list[int] | None = None,
|
||||||
|
verify_with: str = "",
|
||||||
|
expires_when: str = "",
|
||||||
|
clear: list[str] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update an existing Scribe note. Only explicitly provided fields are changed.
|
"""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
|
supersedes: Replace the ids of earlier notes this one replaces
|
||||||
(set-semantics). None = leave unchanged; [] = clear all. See
|
(set-semantics). None = leave unchanged; [] = clear all. See
|
||||||
create_note for when to reach for it.
|
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()
|
uid = current_user_id()
|
||||||
fields: dict = {}
|
fields: dict = {}
|
||||||
@@ -199,7 +265,13 @@ async def update_note(
|
|||||||
fields["tags"] = tags
|
fields["tags"] = tags
|
||||||
if project_id:
|
if project_id:
|
||||||
fields["project_id"] = 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:
|
if note is None:
|
||||||
raise ValueError(f"note {note_id} not found")
|
raise ValueError(f"note {note_id} not found")
|
||||||
if system_ids is not None:
|
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:
|
async def delete_note(note_id: int) -> dict:
|
||||||
"""Move a Scribe note to the trash (recoverable). Restore via restore(batch_id)."""
|
"""Move a Scribe note to the trash (recoverable). Restore via restore(batch_id)."""
|
||||||
uid = current_user_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)
|
batch = await trash_svc.delete(uid, "note", note_id)
|
||||||
if batch is None:
|
if batch is None:
|
||||||
raise ValueError(f"note {note_id} not found")
|
raise ValueError(f"note {note_id} not found")
|
||||||
return {"deleted_batch_id": batch,
|
return {"deleted": note_id, "title": title, "deleted_batch_id": batch,
|
||||||
"message": f"Note {note_id} moved to trash. Restore with restore('{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:
|
def register(mcp) -> None:
|
||||||
@@ -267,5 +441,7 @@ def register(mcp) -> None:
|
|||||||
update_note,
|
update_note,
|
||||||
find_duplicate_records,
|
find_duplicate_records,
|
||||||
delete_note,
|
delete_note,
|
||||||
|
notes_due_for_verification,
|
||||||
|
mark_note_verified,
|
||||||
):
|
):
|
||||||
mcp.tool(name=fn.__name__)(fn)
|
mcp.tool(name=fn.__name__)(fn)
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ async def create_process(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a stored process (a reusable saved prompt).
|
"""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
|
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
|
procedure — the steps, the taxonomy, the quality bar, the failure modes
|
||||||
worth guarding. It must not force anything the invoking conversation
|
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,
|
"design_system": design_system,
|
||||||
"milestone_summary": milestone_summary,
|
"milestone_summary": milestone_summary,
|
||||||
**rulebooks_svc.rules_payload(applicable),
|
**rulebooks_svc.rules_payload(applicable, user_id=uid, source="enter_project"),
|
||||||
"open_tasks": [
|
"open_tasks": [
|
||||||
{
|
{
|
||||||
"id": t.id, "title": t.title, "status": t.status,
|
"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(
|
applicable = await rulebooks_svc.get_applicable_rules(
|
||||||
project_id=project_id, user_id=uid,
|
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
|
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 dedup as dedup_svc
|
||||||
from scribe.services import rulebooks as rulebooks_svc
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
from scribe.services import trash as trash_svc
|
from scribe.services import trash as trash_svc
|
||||||
|
from scribe.services.rule_usage import record_rule_pulled, record_rule_surfaced
|
||||||
|
|
||||||
|
|
||||||
# ── Rulebook CRUD ───────────────────────────────────────────────────────
|
# ── Rulebook CRUD ───────────────────────────────────────────────────────
|
||||||
@@ -122,8 +123,9 @@ async def delete_rulebook(rulebook_id: int, confirmed: bool = False) -> dict:
|
|||||||
"confirmed_required": True,
|
"confirmed_required": True,
|
||||||
}
|
}
|
||||||
batch = await trash_svc.delete(uid, "rulebook", rulebook_id)
|
batch = await trash_svc.delete(uid, "rulebook", rulebook_id)
|
||||||
return {"deleted": rulebook_id, "deleted_batch_id": batch,
|
return {"deleted": rulebook_id, "title": rb.title, "deleted_batch_id": batch,
|
||||||
"message": f"Moved to trash. Restore with restore('{batch}')."}
|
"message": f'Rulebook {rulebook_id} ("{rb.title}") moved to trash. '
|
||||||
|
f"Restore with restore('{batch}')."}
|
||||||
|
|
||||||
|
|
||||||
# ── Topic CRUD ─────────────────────────────────────────────────────────
|
# ── Topic CRUD ─────────────────────────────────────────────────────────
|
||||||
@@ -192,8 +194,9 @@ async def delete_topic(topic_id: int, confirmed: bool = False) -> dict:
|
|||||||
"confirmed_required": True,
|
"confirmed_required": True,
|
||||||
}
|
}
|
||||||
batch = await trash_svc.delete(uid, "topic", topic_id)
|
batch = await trash_svc.delete(uid, "topic", topic_id)
|
||||||
return {"deleted": topic_id, "deleted_batch_id": batch,
|
return {"deleted": topic_id, "title": topic.title, "deleted_batch_id": batch,
|
||||||
"message": f"Moved to trash. Restore with restore('{batch}')."}
|
"message": f'Topic {topic_id} ("{topic.title}") moved to trash. '
|
||||||
|
f"Restore with restore('{batch}')."}
|
||||||
|
|
||||||
|
|
||||||
# ── Rule CRUD ──────────────────────────────────────────────────────────
|
# ── Rule CRUD ──────────────────────────────────────────────────────────
|
||||||
@@ -262,7 +265,25 @@ async def list_always_on_rules(project_id: int = 0) -> dict:
|
|||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
rules = await rulebooks_svc.list_always_on_rules(uid, project_id=project_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:
|
async def get_rule(rule_id: int) -> dict:
|
||||||
@@ -277,6 +298,11 @@ async def get_rule(rule_id: int) -> dict:
|
|||||||
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
||||||
if rule is None:
|
if rule is None:
|
||||||
raise ValueError(f"rule {rule_id} not found")
|
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)
|
return await rulebooks_svc.rule_detail(uid, rule)
|
||||||
|
|
||||||
|
|
||||||
@@ -289,6 +315,61 @@ async def create_rule(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new rule in a rulebook (a SHARED rule — keep it general).
|
"""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
|
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
|
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 —
|
projects that opt in. So a rulebook rule must read as a general standard —
|
||||||
@@ -407,6 +488,23 @@ async def create_project_rule(
|
|||||||
the rule is returned in get_project's applicable_rules (under
|
the rule is returned in get_project's applicable_rules (under
|
||||||
project_rules) and in list_rules(project_id=...).
|
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
|
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
|
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
|
relate_rules(kind="overrides") to the rule it supersedes, so the pair stays
|
||||||
@@ -541,6 +639,73 @@ async def update_rule(
|
|||||||
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
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:
|
async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
|
||||||
"""Move a rule to the trash (recoverable). Requires confirmed=True."""
|
"""Move a rule to the trash (recoverable). Requires confirmed=True."""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
@@ -556,8 +721,9 @@ async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
|
|||||||
"confirmed_required": True,
|
"confirmed_required": True,
|
||||||
}
|
}
|
||||||
batch = await trash_svc.delete(uid, "rule", rule_id)
|
batch = await trash_svc.delete(uid, "rule", rule_id)
|
||||||
return {"deleted": rule_id, "deleted_batch_id": batch,
|
return {"deleted": rule_id, "title": rule.title, "deleted_batch_id": batch,
|
||||||
"message": f"Moved to trash. Restore with restore('{batch}')."}
|
"message": f'Rule {rule_id} ("{rule.title}") moved to trash. '
|
||||||
|
f"Restore with restore('{batch}')."}
|
||||||
|
|
||||||
|
|
||||||
# ── Subscriptions ──────────────────────────────────────────────────────
|
# ── Subscriptions ──────────────────────────────────────────────────────
|
||||||
@@ -825,5 +991,6 @@ def register(mcp) -> None:
|
|||||||
suppress_topic_for_project, unsuppress_topic_for_project,
|
suppress_topic_for_project, unsuppress_topic_for_project,
|
||||||
exclude_always_on_rulebook, include_always_on_rulebook,
|
exclude_always_on_rulebook, include_always_on_rulebook,
|
||||||
rules_due_for_verification, mark_rule_verified,
|
rules_due_for_verification, mark_rule_verified,
|
||||||
|
rule_history,
|
||||||
):
|
):
|
||||||
mcp.tool(name=fn.__name__)(fn)
|
mcp.tool(name=fn.__name__)(fn)
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ async def search(
|
|||||||
return await _search_rules(uid, q, limit)
|
return await _search_rules(uid, q, limit)
|
||||||
is_task = {"note": False, "task": True}.get(content_type) # None => any
|
is_task = {"note": False, "task": True}.get(content_type) # None => any
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
|
report: dict = {}
|
||||||
raw = await semantic_search_notes(
|
raw = await semantic_search_notes(
|
||||||
uid, q, limit=limit, is_task=is_task,
|
uid, q, limit=limit, is_task=is_task,
|
||||||
project_id=project_id or None,
|
project_id=project_id or None,
|
||||||
@@ -119,12 +120,14 @@ async def search(
|
|||||||
# An explicit search reaches everything the operator may read, including
|
# An explicit search reaches everything the operator may read, including
|
||||||
# records shared with them one-to-one.
|
# records shared with them one-to-one.
|
||||||
scope="read",
|
scope="read",
|
||||||
|
report=report,
|
||||||
)
|
)
|
||||||
record_retrieval(
|
record_retrieval(
|
||||||
user_id=uid, source="mcp_search", query=q,
|
user_id=uid, source="mcp_search", query=q,
|
||||||
threshold=DEFAULT_SIMILARITY_THRESHOLD, limit=limit,
|
threshold=DEFAULT_SIMILARITY_THRESHOLD, limit=limit,
|
||||||
project_id=project_id or None, is_task=is_task, results=raw,
|
project_id=project_id or None, is_task=is_task, results=raw,
|
||||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||||
|
best_available=report.get("best_available_score"),
|
||||||
)
|
)
|
||||||
owners = await owner_names_for(
|
owners = await owner_names_for(
|
||||||
{int(note.user_id) for _s, note in raw if note.user_id != uid}
|
{int(note.user_id) for _s, note in raw if note.user_id != uid}
|
||||||
@@ -158,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
|
hand-probing the live instance, which is how the last such decision had to
|
||||||
be made.
|
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`,
|
`sources` — per retrieval surface (`auto_inject`, `write_path`,
|
||||||
`mcp_search`, …), from `retrieval_logs`: `calls`, `zero_result_calls`,
|
`mcp_search`, …), from `retrieval_logs`: `calls`, `zero_result_calls`,
|
||||||
`cleared_threshold` (how often the best hit beat the threshold in force for
|
`near_misses`, the `top_score` spread (p10/p50/p90/min/max),
|
||||||
that call), the `top_score` spread (p10/p50/p90/min/max), `avg_result_count`
|
`avg_result_count` and `p90_duration_ms`.
|
||||||
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.
|
|
||||||
|
|
||||||
`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
|
`retrieval_logs` cannot be indexed at: `surfaced` (ranked surfacings — a
|
||||||
scored surface CHOSE the record), `ambient` (the rest), `pulled` split into
|
scored surface CHOSE the record), `ambient` (the rest), `pulled` split into
|
||||||
`pulled_by_agent` / `pulled_by_human`, the distinct-note counts, and
|
`pulled_by_agent` / `pulled_by_human`, the distinct-note counts, and
|
||||||
@@ -182,6 +222,84 @@ async def retrieval_telemetry(days: int = 30) -> dict:
|
|||||||
tuned against — only by a pull the agent made. Aggregating across the
|
tuned against — only by a pull the agent made. Aggregating across the
|
||||||
mcp_/rest_ prefix would silently answer the wrong one.
|
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
|
Scoped to your own telemetry — a retrieval log records what your agent
|
||||||
asked for, query text included, and is not a shared record kind.
|
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
|
"""Record a shape in the project's pattern library, so every later
|
||||||
instance starts from it instead of re-deriving it.
|
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,
|
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
|
a route handler, a service class, a helper, a test scaffold — not only
|
||||||
when something is judged "reusable": the builder of the first instance
|
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.
|
that should survive, prefer merge_snippets — that keeps the call sites.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
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):
|
if not await snippets_svc.delete_snippet(uid, snippet_id):
|
||||||
raise ValueError(f"snippet {snippet_id} not found")
|
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:
|
async def merge_snippets(target_id: int, source_ids: list[int]) -> dict:
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ async def get_task(task_id: int) -> dict:
|
|||||||
applicable = await rulebooks_svc.get_applicable_rules(
|
applicable = await rulebooks_svc.get_applicable_rules(
|
||||||
project_id=note.project_id, user_id=uid,
|
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))
|
data.update(await access_svc.describe_provenance(uid, note))
|
||||||
# Same reasoning as get_note's record_pulled, and this is the tool where it
|
# 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
|
# matters MOST: auto-inject ranks kind-blind over a corpus that is
|
||||||
@@ -135,6 +135,13 @@ async def create_task(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new task in Scribe.
|
"""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:
|
Args:
|
||||||
title: Task title (required).
|
title: Task title (required).
|
||||||
body: Markdown description / notes for the task.
|
body: Markdown description / notes for the task.
|
||||||
@@ -323,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,
|
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
|
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
|
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.
|
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
|
Creates a MILESTONE that IS the plan: its `body` is seeded with a design
|
||||||
@@ -355,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.
|
"""Move a Scribe task (or plan) to the trash (recoverable). Sub-tasks go with it.
|
||||||
Restore via restore(batch_id)."""
|
Restore via restore(batch_id)."""
|
||||||
uid = current_user_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)
|
batch = await trash_svc.delete(uid, "task", task_id)
|
||||||
if batch is None:
|
if batch is None:
|
||||||
raise ValueError(f"task {task_id} not found")
|
raise ValueError(f"task {task_id} not found")
|
||||||
return {"deleted_batch_id": batch,
|
return {"deleted": task_id, "title": title, "deleted_batch_id": batch,
|
||||||
"message": f"Task {task_id} moved to trash. Restore with restore('{batch}')."}
|
"message": f'Task {task_id} ("{title}") moved to trash. '
|
||||||
|
f"Restore with restore('{batch}')."}
|
||||||
|
|
||||||
|
|
||||||
def register(mcp) -> None:
|
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.embedding import NoteEmbedding, RuleEmbedding # noqa: E402, F401
|
||||||
from scribe.models.retrieval_log import RetrievalLog # 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.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.project import Project # noqa: E402, F401
|
||||||
from scribe.models.milestone import Milestone # 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.task_log import TaskLog # noqa: E402, F401
|
||||||
from scribe.models.note_draft import NoteDraft # 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.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.note_supersession import NoteSupersession # noqa: E402, F401
|
||||||
from scribe.models.group import Group, GroupMembership # noqa: E402, F401
|
from scribe.models.group import Group, GroupMembership # noqa: E402, F401
|
||||||
from scribe.models.share import NoteShare, ProjectShare # 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).
|
"""One embedding vector per CHUNK of a rule (milestone 307, note 3026).
|
||||||
|
|
||||||
A SIBLING of NoteEmbedding rather than a generalisation of it, decided
|
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.
|
- The embedding ROW could have been made polymorphic. The SEARCH could not.
|
||||||
`semantic_search_notes` is a long function of Note-specific scoping —
|
`semantic_search_notes` is a long function of Note-specific scoping —
|
||||||
|
|||||||
@@ -94,9 +94,40 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
# name/language/signature/locations live here so they can be INDEXED. The
|
# name/language/signature/locations live here so they can be INDEXED. The
|
||||||
# body keeps the same facts in readable markdown and remains what gets
|
# 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
|
# 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
|
# display — and it is DERIVED, so every path that writes a snippet's body
|
||||||
# back to parsing the body (see services/snippets.snippet_fields).
|
# 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)
|
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__ = (
|
__table_args__ = (
|
||||||
Index("ix_notes_tags", "tags", postgresql_using="gin"),
|
Index("ix_notes_tags", "tags", postgresql_using="gin"),
|
||||||
@@ -137,6 +168,14 @@ class Note(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
"is_task": self.is_task,
|
"is_task": self.is_task,
|
||||||
"note_type": self.note_type or "note",
|
"note_type": self.note_type or "note",
|
||||||
"task_kind": self.task_kind,
|
"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),
|
"created_at": iso(self.created_at),
|
||||||
"updated_at": iso(self.updated_at),
|
"updated_at": iso(self.updated_at),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,8 +42,26 @@ class RetrievalLog(Base):
|
|||||||
# False=notes, NULL=any.
|
# False=notes, NULL=any.
|
||||||
is_task: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
is_task: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||||
result_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
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)
|
top_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
min_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.
|
# [{"id": int, "score": float, "rank": int}, ...], highest-first.
|
||||||
result_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
result_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
|
duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
@@ -67,6 +85,7 @@ class RetrievalLog(Base):
|
|||||||
"project_id": self.project_id,
|
"project_id": self.project_id,
|
||||||
"is_task": self.is_task,
|
"is_task": self.is_task,
|
||||||
"result_count": self.result_count,
|
"result_count": self.result_count,
|
||||||
|
"suppressed_count": self.suppressed_count,
|
||||||
"top_score": self.top_score,
|
"top_score": self.top_score,
|
||||||
"min_score": self.min_score,
|
"min_score": self.min_score,
|
||||||
"result_ids": self.result_ids,
|
"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
|
||||||
@@ -10,6 +10,61 @@ async def health():
|
|||||||
return jsonify({"status": "ok"})
|
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")
|
@api.route("/version")
|
||||||
async def 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
|
import logging
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
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.auth import get_current_user_id, login_required
|
||||||
from scribe.routes.utils import parse_pagination
|
from scribe.routes.utils import parse_pagination
|
||||||
from scribe.services.access import label_shared_items
|
from scribe.services.access import label_shared_items
|
||||||
|
from scribe.services.knowledge import FACET_TYPES
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
knowledge_bp = Blueprint("knowledge", __name__, url_prefix="/api/knowledge")
|
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"}
|
_VALID_SORTS = {"modified", "created", "alpha", "type"}
|
||||||
|
|
||||||
|
|
||||||
@@ -21,7 +27,9 @@ async def list_knowledge():
|
|||||||
"""Return paginated knowledge objects with optional filtering.
|
"""Return paginated knowledge objects with optional filtering.
|
||||||
|
|
||||||
Query params:
|
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)
|
tags — comma-separated tag filter (AND logic)
|
||||||
sort — modified|created|alpha|type (default: modified)
|
sort — modified|created|alpha|type (default: modified)
|
||||||
q — search query (semantic when provided, keyword fallback)
|
q — search query (semantic when provided, keyword fallback)
|
||||||
@@ -127,7 +135,7 @@ async def get_knowledge_batch():
|
|||||||
@knowledge_bp.route("/tags", methods=["GET"])
|
@knowledge_bp.route("/tags", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
async def list_knowledge_tags():
|
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()
|
uid = get_current_user_id()
|
||||||
note_type = request.args.get("type", "").strip().lower() or None
|
note_type = request.args.get("type", "").strip().lower() or None
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ from scribe.services.notes import (
|
|||||||
get_note_for_user,
|
get_note_for_user,
|
||||||
get_or_create_note_by_title,
|
get_or_create_note_by_title,
|
||||||
list_notes,
|
list_notes,
|
||||||
|
mark_note_verified,
|
||||||
|
notes_due_for_verification,
|
||||||
update_note,
|
update_note,
|
||||||
|
verification_row,
|
||||||
)
|
)
|
||||||
from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft
|
from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft
|
||||||
from scribe.services import dedup as dedup_svc
|
from scribe.services import dedup as dedup_svc
|
||||||
@@ -112,6 +115,8 @@ async def create_note_route():
|
|||||||
priority=priority,
|
priority=priority,
|
||||||
due_date=due_date,
|
due_date=due_date,
|
||||||
note_type=note_type,
|
note_type=note_type,
|
||||||
|
verify_with=data.get("verify_with"),
|
||||||
|
expires_when=data.get("expires_when"),
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return jsonify({"error": str(e)}), 400
|
return jsonify({"error": str(e)}), 400
|
||||||
@@ -248,7 +253,14 @@ async def update_note_route(note_id: int):
|
|||||||
owner_uid = note_obj.user_id
|
owner_uid = note_obj.user_id
|
||||||
data = await request.get_json()
|
data = await request.get_json()
|
||||||
fields = {}
|
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:
|
if key in data:
|
||||||
fields[key] = data[key]
|
fields[key] = data[key]
|
||||||
if "due_date" in data:
|
if "due_date" in data:
|
||||||
@@ -490,3 +502,62 @@ async def graph_route():
|
|||||||
shared_tags = request.args.get("shared_tags", "false").lower() == "true"
|
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)
|
graph = await build_note_graph(uid, project_id=project_id, include_shared_tags=shared_tags)
|
||||||
return jsonify(graph)
|
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)
|
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")
|
@plugin_bp.get("/prior-art")
|
||||||
@login_required
|
@login_required
|
||||||
async def write_path_prior_art():
|
async def write_path_prior_art():
|
||||||
@@ -138,6 +178,11 @@ async def write_path_prior_art():
|
|||||||
or `canon:<snippet_id>`) already named this
|
or `canon:<snippet_id>`) already named this
|
||||||
session by the ledger arm (#2900); its own
|
session by the ledger arm (#2900); its own
|
||||||
channel, like the two above.
|
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
|
shapes (opt) — comma-separated `kind:name` definitions the hook
|
||||||
found in (or enclosing) the payload, kind being
|
found in (or enclosing) the payload, kind being
|
||||||
css|sym. The shape ledger's write-path feed
|
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()
|
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"))
|
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 "")
|
shapes = _parse_shapes(request.args.get("shapes") or "")
|
||||||
api_key = getattr(g, "api_key", None)
|
api_key = getattr(g, "api_key", None)
|
||||||
may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
|
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 "",
|
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
|
||||||
exclude_derive=exclude_derive,
|
exclude_derive=exclude_derive,
|
||||||
exclude_rule_ids=exclude_rule_ids,
|
exclude_rule_ids=exclude_rule_ids,
|
||||||
|
rules_etag=rules_etag,
|
||||||
)
|
)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ from quart import Blueprint, jsonify, request
|
|||||||
from scribe.auth import get_current_user_id, login_required
|
from scribe.auth import get_current_user_id, login_required
|
||||||
import scribe.services.rulebooks as rulebooks_svc
|
import scribe.services.rulebooks as rulebooks_svc
|
||||||
from scribe.services.trash import delete as trash_delete
|
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")
|
rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
|
||||||
|
|
||||||
@@ -136,13 +139,24 @@ async def list_rules():
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return jsonify({"error": "rulebook_id, topic_id, project_id must be integers"}), 400
|
return jsonify({"error": "rulebook_id, topic_id, project_id must be integers"}), 400
|
||||||
|
|
||||||
|
uid = get_current_user_id()
|
||||||
rows = await rulebooks_svc.list_rules(
|
rows = await rulebooks_svc.list_rules(
|
||||||
user_id=get_current_user_id(),
|
user_id=uid,
|
||||||
rulebook_id=rulebook_id,
|
rulebook_id=rulebook_id,
|
||||||
topic_id=topic_id,
|
topic_id=topic_id,
|
||||||
project_id=project_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")
|
@rulebooks_bp.post("/rulebook-topics/<int:topic_id>/rules")
|
||||||
@@ -182,6 +196,11 @@ async def get_rule(rule_id: int):
|
|||||||
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
||||||
if rule is None:
|
if rule is None:
|
||||||
return jsonify({"error": "rule not found"}), 404
|
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))
|
return jsonify(await rulebooks_svc.rule_detail(uid, rule))
|
||||||
|
|
||||||
|
|
||||||
@@ -206,6 +225,42 @@ async def update_rule(rule_id: int):
|
|||||||
return jsonify(await rulebooks_svc.rule_detail(uid, rule, data.get("system_ids")))
|
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")
|
@rulebooks_bp.post("/rules/<int:rule_id>/relations")
|
||||||
@login_required
|
@login_required
|
||||||
async def relate_rules(rule_id: int):
|
async def relate_rules(rule_id: int):
|
||||||
|
|||||||
@@ -44,17 +44,20 @@ async def search_route():
|
|||||||
project_id = request.args.get("project_id", type=int)
|
project_id = request.args.get("project_id", type=int)
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
|
report: dict = {}
|
||||||
results = await semantic_search_notes(
|
results = await semantic_search_notes(
|
||||||
uid, q, limit=limit, is_task=is_task, threshold=_REST_SEARCH_THRESHOLD,
|
uid, q, limit=limit, is_task=is_task, threshold=_REST_SEARCH_THRESHOLD,
|
||||||
project_id=project_id, system_id=system_id,
|
project_id=project_id, system_id=system_id,
|
||||||
# The user typed this, so it reaches everything they may read.
|
# The user typed this, so it reaches everything they may read.
|
||||||
scope="read",
|
scope="read",
|
||||||
|
report=report,
|
||||||
)
|
)
|
||||||
record_retrieval(
|
record_retrieval(
|
||||||
user_id=uid, source="rest_search", query=q,
|
user_id=uid, source="rest_search", query=q,
|
||||||
threshold=_REST_SEARCH_THRESHOLD, limit=limit,
|
threshold=_REST_SEARCH_THRESHOLD, limit=limit,
|
||||||
project_id=project_id, is_task=is_task, results=results,
|
project_id=project_id, is_task=is_task, results=results,
|
||||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||||
|
best_available=report.get("best_available_score"),
|
||||||
)
|
)
|
||||||
owners = await owner_names_for(
|
owners = await owner_names_for(
|
||||||
{int(note.user_id) for _s, note in results if note.user_id != uid}
|
{int(note.user_id) for _s, note in results if note.user_id != uid}
|
||||||
|
|||||||
@@ -6,20 +6,63 @@ write that never errors and never lands (the #2663 GC footgun). This module is
|
|||||||
the one place that gets the pattern right: strong references in ``_pending``,
|
the one place that gets the pattern right: strong references in ``_pending``,
|
||||||
discarded on completion, with failures logged at WARNING instead of vanishing.
|
discarded on completion, with failures logged at WARNING instead of vanishing.
|
||||||
|
|
||||||
``note_usage`` and ``retrieval_telemetry`` predate this module and carry their
|
``retrieval_telemetry`` predates this module and keeps its own copy, because
|
||||||
own copies with bespoke canary semantics; new fire-and-forget callers use this
|
its canary is a genuinely different shape — one process-wide flag and no
|
||||||
instead of writing a fourth copy.
|
AppLog row. ``note_usage`` and ``rule_usage`` share ``report_telemetry_failure``
|
||||||
|
below. New fire-and-forget callers use ``spawn`` rather than writing another
|
||||||
|
copy of the strong-reference dance.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import traceback
|
||||||
from collections.abc import Coroutine
|
from collections.abc import Coroutine
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_pending: set[asyncio.Task] = set()
|
_pending: set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
# Sites that have already dropped their once-per-process AppLog row, keyed
|
||||||
|
# "<subsystem>:<site>". A readout can run on every list render — without this,
|
||||||
|
# a broken table turns the error log into a firehose that buries the finding it
|
||||||
|
# exists to surface.
|
||||||
|
_reported: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
async def report_telemetry_failure(subsystem: str, site: str) -> None:
|
||||||
|
"""Make a swallowed telemetry failure visible. Call from an except block.
|
||||||
|
|
||||||
|
WARNING to the process log every time; one AppLog error row per process per
|
||||||
|
(subsystem, site) so the admin UI shows the outage without host access.
|
||||||
|
|
||||||
|
THIS IS NOT DECORATION. #2663 is the record of a telemetry subsystem running
|
||||||
|
at zero for weeks — every counter reading empty, indistinguishable from
|
||||||
|
"nobody uses this" — because every failure went to ``logger.debug``. A
|
||||||
|
subsystem whose failures are all invisible cannot report its own death.
|
||||||
|
|
||||||
|
The AppLog write is itself guarded: when the database is down it fails too,
|
||||||
|
and that is fine. The WARNING already said so, and a canary must never take
|
||||||
|
down the surface it watches.
|
||||||
|
"""
|
||||||
|
logger.warning("%s telemetry %s failed", subsystem, site, exc_info=True)
|
||||||
|
key = f"{subsystem}:{site}"
|
||||||
|
if key in _reported:
|
||||||
|
return
|
||||||
|
_reported.add(key)
|
||||||
|
try:
|
||||||
|
from scribe.services.logging import log_error
|
||||||
|
|
||||||
|
await log_error(
|
||||||
|
endpoint=subsystem,
|
||||||
|
error_type=f"{subsystem}_{site}_failed",
|
||||||
|
error_message=f"{subsystem} telemetry {site} is failing; "
|
||||||
|
"usage counters will read zero until this is fixed",
|
||||||
|
traceback=traceback.format_exc(),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("%s canary write failed", subsystem, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def spawn(coro: Coroutine, *, site: str) -> None:
|
def spawn(coro: Coroutine, *, site: str) -> None:
|
||||||
"""Schedule ``coro`` fire-and-forget; ``site`` names it in failure logs.
|
"""Schedule ``coro`` fire-and-forget; ``site`` names it in failure logs.
|
||||||
|
|||||||
+318
-10
@@ -9,8 +9,10 @@ from scribe.models.note import Note
|
|||||||
from scribe.models.note_draft import NoteDraft
|
from scribe.models.note_draft import NoteDraft
|
||||||
from scribe.models.note_supersession import NoteSupersession
|
from scribe.models.note_supersession import NoteSupersession
|
||||||
from scribe.models.note_version import NoteVersion
|
from scribe.models.note_version import NoteVersion
|
||||||
|
from scribe.models.rule_version import RuleVersion
|
||||||
from scribe.models.design_system import DesignSystem, DesignToken
|
from scribe.models.design_system import DesignSystem, DesignToken
|
||||||
from scribe.models.note_usage import NoteUsageEvent
|
from scribe.models.note_usage import NoteUsageEvent
|
||||||
|
from scribe.models.rule_usage import RuleUsageEvent
|
||||||
from scribe.models.canonical_system import CanonicalSystem
|
from scribe.models.canonical_system import CanonicalSystem
|
||||||
from scribe.models.rulebook import RuleRelation, rule_systems as rule_systems_t
|
from scribe.models.rulebook import RuleRelation, rule_systems as rule_systems_t
|
||||||
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
|
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
|
||||||
@@ -50,8 +52,23 @@ logger = logging.getLogger(__name__)
|
|||||||
# ones (reference/hook) travel too, cheaply, and the next refresh refreshes them.
|
# ones (reference/hook) travel too, cheaply, and the next refresh refreshes them.
|
||||||
# v10 (2026-08) added projects.inception + project_rulebook_exclusions
|
# v10 (2026-08) added projects.inception + project_rulebook_exclusions
|
||||||
# (milestone 297): the WHY a project inherits what it does, and its opt-outs.
|
# (milestone 297): the WHY a project inherits what it does, and its opt-outs.
|
||||||
|
# v11 (2026-08) added the note verification trio — notes.verify_with /
|
||||||
|
# expires_when / verified_at (milestone 317).
|
||||||
|
# v12 (2026-08) closed #3182: the nine Note columns that had been missing for
|
||||||
|
# years (note_type, task_kind, arose_from_id, data, description, the
|
||||||
|
# recurrence pair, started_at, completed_at), plus milestones.body — which IS
|
||||||
|
# the plan — and repo_bindings.ref. Until v12 a restore reported success and
|
||||||
|
# handed back a corpus with every snippet and process flattened into a plain
|
||||||
|
# note, every issue and spike into `work`, and every plan reduced to a title.
|
||||||
|
# _COLUMN_EXCLUSIONS and its guard landed with it, so the next such column
|
||||||
|
# fails the build instead.
|
||||||
|
# v13 (2026-08) added rule_versions — a rule's edit history (milestone 323).
|
||||||
|
# v14 (2026-09) added rule_usage_events — the rule twin of note_usage_events
|
||||||
|
# (milestone 333). Carrying it is the WHOLE REASON the table is separate: the
|
||||||
|
# note importer maps note_id through note_id_map, so a rule id parked there
|
||||||
|
# would restore attached to whatever note took that number.
|
||||||
# Bump when the serialized schema changes.
|
# Bump when the serialized schema changes.
|
||||||
BACKUP_VERSION = 10
|
BACKUP_VERSION = 14
|
||||||
|
|
||||||
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
|
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
|
||||||
# below, these two lists must together account for the entire schema — which is
|
# below, these two lists must together account for the entire schema — which is
|
||||||
@@ -77,6 +94,14 @@ _BACKED_UP = [
|
|||||||
"canonical_systems",
|
"canonical_systems",
|
||||||
# v10 (2026-08): a rule's area tag and its typed edges (milestone 307).
|
# v10 (2026-08): a rule's area tag and its typed edges (milestone 307).
|
||||||
"rule_systems", "rule_relations",
|
"rule_systems", "rule_relations",
|
||||||
|
# v13 (2026-08): a rule's edit history (milestone 323). note_versions has
|
||||||
|
# always travelled; its sibling has no excuse not to.
|
||||||
|
"rule_versions",
|
||||||
|
# v14 (2026-09): rule usage telemetry (milestone 333). Same argument
|
||||||
|
# note_usage_events makes for itself — pull-through is only ever
|
||||||
|
# accumulated, so a restore that dropped it would silently reset the
|
||||||
|
# measurement to zero while everything still looked fine.
|
||||||
|
"rule_usage_events",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||||
@@ -108,6 +133,88 @@ _NOT_INCLUDED = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Columns a backed-up table deliberately does NOT export, per table. Paired
|
||||||
|
# with the column-coverage guard in tests/test_services_backup.py, this is
|
||||||
|
# _NOT_INCLUDED's shape one level down — and it exists because the table guard
|
||||||
|
# could not see the failure it was written to stop.
|
||||||
|
#
|
||||||
|
# #2293 was six whole tables missing. #3182 was NINE COLUMNS missing from a
|
||||||
|
# table that had been "covered" for years: note_type and task_kind, so every
|
||||||
|
# snippet and process restored as a plain note and every issue and spike as
|
||||||
|
# `work`; arose_from_id, so every provenance edge vanished; the recurrence
|
||||||
|
# pair, so recurring tasks stopped recurring; milestones.body, which IS the
|
||||||
|
# plan; repo_bindings.ref, the branch a ledger follows. Every one arrived the
|
||||||
|
# same way — a column added to the model and the migration, both of which fail
|
||||||
|
# loudly, and then never added to the serialiser, which fails silently.
|
||||||
|
#
|
||||||
|
# So: a new column on a backed-up table now fails the build unless it is either
|
||||||
|
# exported or named here with a reason. "I forgot" is no longer expressible.
|
||||||
|
_COLUMN_EXCLUSIONS: dict[str, set[str]] = {
|
||||||
|
# Trash is not exported at all, so neither is the batch id that groups a
|
||||||
|
# deletion for restore(). Uniform across every soft-deletable table.
|
||||||
|
"users": set(),
|
||||||
|
"projects": {
|
||||||
|
"deleted_at", "deleted_batch_id",
|
||||||
|
# Credentials-adjacent, same reasoning as api_keys and
|
||||||
|
# forge_connections: a restored project falls back to keyring-by-host
|
||||||
|
# resolution, which is the documented unpinned behaviour (#2778).
|
||||||
|
"forge_connection_id",
|
||||||
|
},
|
||||||
|
"milestones": {"deleted_at", "deleted_batch_id"},
|
||||||
|
"notes": {"deleted_at", "deleted_batch_id"},
|
||||||
|
"task_logs": set(),
|
||||||
|
"note_drafts": set(),
|
||||||
|
"note_versions": set(),
|
||||||
|
"settings": set(),
|
||||||
|
"rulebooks": {"deleted_at", "deleted_batch_id"},
|
||||||
|
"rulebook_topics": {"deleted_at", "deleted_batch_id"},
|
||||||
|
"rules": {"deleted_at", "deleted_batch_id"},
|
||||||
|
"systems": {
|
||||||
|
"deleted_at", "deleted_batch_id",
|
||||||
|
# Travels as `canonical_slug`: the catalog is global and its ids are
|
||||||
|
# per-install, so an id would restore pointing at whatever area
|
||||||
|
# happened to land on that number.
|
||||||
|
"canonical_id",
|
||||||
|
"created_at", "updated_at",
|
||||||
|
},
|
||||||
|
"canonical_systems": {
|
||||||
|
"deleted_at", "deleted_batch_id",
|
||||||
|
# Matched on SLUG at restore, so a target install that already seeded
|
||||||
|
# the standard vocabulary reuses its own rows rather than colliding.
|
||||||
|
"id", "created_at", "updated_at",
|
||||||
|
},
|
||||||
|
# Edge tables: the id is regenerated on insert, and the pair IS the row.
|
||||||
|
"record_systems": {"id", "created_at"},
|
||||||
|
"note_supersessions": {"id", "created_at"},
|
||||||
|
"rule_relations": {"id", "created_at"},
|
||||||
|
"note_usage_events": {"id"},
|
||||||
|
# Same as the note twin: the surrogate key is re-issued on insert.
|
||||||
|
"rule_usage_events": {"id"},
|
||||||
|
"design_systems": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"},
|
||||||
|
"design_tokens": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"},
|
||||||
|
"repo_bindings": {"id", "created_at", "updated_at"},
|
||||||
|
# Everything travels. A version row IS the audit trail, so a column left
|
||||||
|
# behind is a fact about a binding instruction that no longer exists
|
||||||
|
# anywhere.
|
||||||
|
"rule_versions": set(),
|
||||||
|
# Serialised via the model's own to_dict(), so a column reaches the backup
|
||||||
|
# the moment it reaches that method — and the guard still catches one that
|
||||||
|
# reaches neither.
|
||||||
|
"code_shapes": {
|
||||||
|
# The PROPOSER's standing suggestion for an unclassified row, not a
|
||||||
|
# judgment: "looks like an instance of #N", or "repeats with no canon".
|
||||||
|
# Every refresh recomputes it and a judgment clears it, so carrying it
|
||||||
|
# would restore stale machine guesses over a tree the proposer has not
|
||||||
|
# seen. Same reasoning as code_shape_consumers in _NOT_INCLUDED —
|
||||||
|
# derived data is regenerated, never restored.
|
||||||
|
"proposed_snippet_id", "proposal_basis", "proposal_score",
|
||||||
|
"proposal_group", "proposed_at", "proposed_sha",
|
||||||
|
},
|
||||||
|
"code_shape_events": set(),
|
||||||
|
"code_shape_uses": set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _dt(val: str | None) -> datetime:
|
def _dt(val: str | None) -> datetime:
|
||||||
return datetime.fromisoformat(val) if val else datetime.now(timezone.utc)
|
return datetime.fromisoformat(val) if val else datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -226,6 +333,17 @@ def _usage_event_rows(rows) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_usage_event_rows(rows) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"user_id": r.user_id, "rule_id": r.rule_id, "event": r.event,
|
||||||
|
"source": r.source,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _code_shape_rows(rows) -> list[dict]:
|
def _code_shape_rows(rows) -> list[dict]:
|
||||||
return [r.to_dict() for r in rows]
|
return [r.to_dict() for r in rows]
|
||||||
|
|
||||||
@@ -240,7 +358,13 @@ def _code_shape_use_rows(rows) -> list[dict]:
|
|||||||
|
|
||||||
def _repo_binding_rows(rows) -> list[dict]:
|
def _repo_binding_rows(rows) -> list[dict]:
|
||||||
return [
|
return [
|
||||||
{"user_id": r.user_id, "project_id": r.project_id, "repo_key": r.repo_key}
|
# `ref` is the branch this binding's ledger follows (#2873). Without
|
||||||
|
# it a restored binding silently falls back to the default branch, and
|
||||||
|
# the shape ledger starts accounting for a different tree (#3182).
|
||||||
|
{
|
||||||
|
"user_id": r.user_id, "project_id": r.project_id,
|
||||||
|
"repo_key": r.repo_key, "ref": r.ref,
|
||||||
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -282,6 +406,11 @@ def _milestone_rows(rows) -> list[dict]:
|
|||||||
{
|
{
|
||||||
"id": m.id, "user_id": m.user_id, "project_id": m.project_id,
|
"id": m.id, "user_id": m.user_id, "project_id": m.project_id,
|
||||||
"title": m.title, "description": m.description, "status": m.status,
|
"title": m.title, "description": m.description, "status": m.status,
|
||||||
|
# THE PLAN. A milestone IS the plan (0066) and `body` is its
|
||||||
|
# design and intent; `description` is only the one-line summary.
|
||||||
|
# Dropping this restored every plan as a title with no reasoning
|
||||||
|
# behind it (#3182).
|
||||||
|
"body": m.body,
|
||||||
"order_index": m.order_index,
|
"order_index": m.order_index,
|
||||||
"created_at": m.created_at.isoformat(),
|
"created_at": m.created_at.isoformat(),
|
||||||
"updated_at": m.updated_at.isoformat(),
|
"updated_at": m.updated_at.isoformat(),
|
||||||
@@ -294,12 +423,45 @@ def _note_rows(rows) -> list[dict]:
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": n.id, "user_id": n.user_id, "title": n.title, "body": n.body,
|
"id": n.id, "user_id": n.user_id, "title": n.title, "body": n.body,
|
||||||
|
"description": n.description,
|
||||||
"tags": n.tags or [], "parent_id": n.parent_id,
|
"tags": n.tags or [], "parent_id": n.parent_id,
|
||||||
"project_id": n.project_id, "milestone_id": n.milestone_id,
|
"project_id": n.project_id, "milestone_id": n.milestone_id,
|
||||||
"status": n.status, "priority": n.priority,
|
"status": n.status, "priority": n.priority,
|
||||||
"due_date": n.due_date.isoformat() if n.due_date else None,
|
"due_date": n.due_date.isoformat() if n.due_date else None,
|
||||||
"created_at": n.created_at.isoformat(),
|
"created_at": n.created_at.isoformat(),
|
||||||
"updated_at": n.updated_at.isoformat(),
|
"updated_at": n.updated_at.isoformat(),
|
||||||
|
# WHAT KIND OF RECORD THIS IS — both typing axes (#3182). Missing
|
||||||
|
# until now, which meant a restore reported success and handed back
|
||||||
|
# a corpus with every snippet and process flattened into a plain
|
||||||
|
# note and every issue and spike into `work`. Nothing recomputes
|
||||||
|
# these; the vocabulary is simply gone.
|
||||||
|
"note_type": n.note_type,
|
||||||
|
"task_kind": n.task_kind,
|
||||||
|
# Provenance — which record caused this one. Re-mapped in the
|
||||||
|
# second pass beside parent_id, never here: the value is an id in
|
||||||
|
# the SOURCE database.
|
||||||
|
"arose_from_id": n.arose_from_id,
|
||||||
|
# The queryable mirror. The only one of these that would self-heal
|
||||||
|
# (backfill_snippet_data rebuilds it from the body at startup), but
|
||||||
|
# a restore should not hand back a corpus that needs a restart to
|
||||||
|
# become searchable by location.
|
||||||
|
"data": n.data,
|
||||||
|
# Lifecycle: when the work actually started and finished, and the
|
||||||
|
# recurrence rule that makes a task come back. Without these a
|
||||||
|
# restored recurring task simply stops recurring.
|
||||||
|
"started_at": n.started_at.isoformat() if n.started_at else None,
|
||||||
|
"completed_at": n.completed_at.isoformat() if n.completed_at else None,
|
||||||
|
"recurrence_rule": n.recurrence_rule,
|
||||||
|
"recurrence_next_spawn_at": (
|
||||||
|
n.recurrence_next_spawn_at.isoformat()
|
||||||
|
if n.recurrence_next_spawn_at else None
|
||||||
|
),
|
||||||
|
# The verification trio (milestone 317, migration 0092). Operator
|
||||||
|
# judgment — "somebody checked this fact, and this is when" —
|
||||||
|
# which nothing can recompute.
|
||||||
|
"verify_with": n.verify_with,
|
||||||
|
"expires_when": n.expires_when,
|
||||||
|
"verified_at": n.verified_at.isoformat() if n.verified_at else None,
|
||||||
}
|
}
|
||||||
for n in rows
|
for n in rows
|
||||||
]
|
]
|
||||||
@@ -342,6 +504,23 @@ def _note_version_rows(rows) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_version_rows(rows) -> list[dict]:
|
||||||
|
"""A rule's edit history. Sibling of _note_version_rows, and it travels for
|
||||||
|
the same reason: a version is the only record of what a binding
|
||||||
|
instruction used to say, and nothing can recompute it."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": rv.id, "rule_id": rv.rule_id, "user_id": rv.user_id,
|
||||||
|
"title": rv.title, "statement": rv.statement, "why": rv.why,
|
||||||
|
"how_to_apply": rv.how_to_apply, "when_to_apply": rv.when_to_apply,
|
||||||
|
"tier": rv.tier, "verify_with": rv.verify_with,
|
||||||
|
"expires_when": rv.expires_when,
|
||||||
|
"created_at": rv.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for rv in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _setting_rows(rows) -> list[dict]:
|
def _setting_rows(rows) -> list[dict]:
|
||||||
return [{"user_id": s.user_id, "key": s.key, "value": s.value} for s in rows]
|
return [{"user_id": s.user_id, "key": s.key, "value": s.value} for s in rows]
|
||||||
|
|
||||||
@@ -423,6 +602,9 @@ async def export_full_backup() -> dict:
|
|||||||
note_versions = (await session.execute(
|
note_versions = (await session.execute(
|
||||||
select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.id)
|
select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.id)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
|
rule_versions = (await session.execute(
|
||||||
|
select(RuleVersion).order_by(RuleVersion.rule_id, RuleVersion.id)
|
||||||
|
)).scalars().all()
|
||||||
settings = (await session.execute(select(Setting))).scalars().all()
|
settings = (await session.execute(select(Setting))).scalars().all()
|
||||||
systems = (await session.execute(select(System))).scalars().all()
|
systems = (await session.execute(select(System))).scalars().all()
|
||||||
canonical_systems = (await session.execute(
|
canonical_systems = (await session.execute(
|
||||||
@@ -447,6 +629,9 @@ async def export_full_backup() -> dict:
|
|||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
design_tokens = (await session.execute(select(DesignToken))).scalars().all()
|
design_tokens = (await session.execute(select(DesignToken))).scalars().all()
|
||||||
usage_events = (await session.execute(select(NoteUsageEvent))).scalars().all()
|
usage_events = (await session.execute(select(NoteUsageEvent))).scalars().all()
|
||||||
|
rule_usage_events = (
|
||||||
|
await session.execute(select(RuleUsageEvent))
|
||||||
|
).scalars().all()
|
||||||
repo_bindings = (await session.execute(select(RepoBinding))).scalars().all()
|
repo_bindings = (await session.execute(select(RepoBinding))).scalars().all()
|
||||||
code_shapes = (await session.execute(select(CodeShape))).scalars().all()
|
code_shapes = (await session.execute(select(CodeShape))).scalars().all()
|
||||||
code_shape_events = (await session.execute(
|
code_shape_events = (await session.execute(
|
||||||
@@ -487,6 +672,7 @@ async def export_full_backup() -> dict:
|
|||||||
"task_logs": _task_log_rows(task_logs),
|
"task_logs": _task_log_rows(task_logs),
|
||||||
"note_drafts": _note_draft_rows(note_drafts),
|
"note_drafts": _note_draft_rows(note_drafts),
|
||||||
"note_versions": _note_version_rows(note_versions),
|
"note_versions": _note_version_rows(note_versions),
|
||||||
|
"rule_versions": _rule_version_rows(rule_versions),
|
||||||
"settings": _setting_rows(settings),
|
"settings": _setting_rows(settings),
|
||||||
"rulebooks": _rulebook_rows(rulebooks),
|
"rulebooks": _rulebook_rows(rulebooks),
|
||||||
"rulebook_topics": _topic_rows(topics),
|
"rulebook_topics": _topic_rows(topics),
|
||||||
@@ -505,6 +691,7 @@ async def export_full_backup() -> dict:
|
|||||||
"design_systems": _design_system_rows(design_systems),
|
"design_systems": _design_system_rows(design_systems),
|
||||||
"design_tokens": _design_token_rows(design_tokens),
|
"design_tokens": _design_token_rows(design_tokens),
|
||||||
"note_usage_events": _usage_event_rows(usage_events),
|
"note_usage_events": _usage_event_rows(usage_events),
|
||||||
|
"rule_usage_events": _rule_usage_event_rows(rule_usage_events),
|
||||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||||
"note_supersessions": _note_supersession_rows(supersessions),
|
"note_supersessions": _note_supersession_rows(supersessions),
|
||||||
"code_shapes": _code_shape_rows(code_shapes),
|
"code_shapes": _code_shape_rows(code_shapes),
|
||||||
@@ -623,6 +810,22 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
.join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id)
|
.join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id)
|
||||||
.where(rule_systems_t.c.rule_id.in_(_rule_ids))
|
.where(rule_systems_t.c.rule_id.in_(_rule_ids))
|
||||||
)).all() if _rule_ids else []
|
)).all() if _rule_ids else []
|
||||||
|
# Scoped through the RULE, not the version's user_id. That column is
|
||||||
|
# the ACTOR (milestone 323), so filtering on it would carry the
|
||||||
|
# versions this user wrote on someone ELSE's rule and drop the ones
|
||||||
|
# someone else wrote on theirs — the opposite of a per-user export.
|
||||||
|
rule_versions = (await session.execute(
|
||||||
|
select(RuleVersion).where(RuleVersion.rule_id.in_(_rule_ids))
|
||||||
|
.order_by(RuleVersion.rule_id, RuleVersion.id)
|
||||||
|
)).scalars().all() if _rule_ids else []
|
||||||
|
# Scoped through the RULE for the same reason the versions above are,
|
||||||
|
# and it is worth restating because the column that looks right is
|
||||||
|
# wrong: `user_id` here is whoever the arm fired FOR, not who owns the
|
||||||
|
# rule. Filtering on it would carry this user's surfacings of someone
|
||||||
|
# ELSE's rule and drop the ones fired for someone else on theirs.
|
||||||
|
rule_usage_events = (await session.execute(
|
||||||
|
select(RuleUsageEvent).where(RuleUsageEvent.rule_id.in_(_rule_ids))
|
||||||
|
)).scalars().all() if _rule_ids else []
|
||||||
rule_relations = (await session.execute(
|
rule_relations = (await session.execute(
|
||||||
select(RuleRelation).where(
|
select(RuleRelation).where(
|
||||||
RuleRelation.from_rule_id.in_(_rule_ids),
|
RuleRelation.from_rule_id.in_(_rule_ids),
|
||||||
@@ -671,6 +874,7 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
"task_logs": _task_log_rows(task_logs),
|
"task_logs": _task_log_rows(task_logs),
|
||||||
"note_drafts": _note_draft_rows(note_drafts),
|
"note_drafts": _note_draft_rows(note_drafts),
|
||||||
"note_versions": _note_version_rows(note_versions),
|
"note_versions": _note_version_rows(note_versions),
|
||||||
|
"rule_versions": _rule_version_rows(rule_versions),
|
||||||
"settings": _setting_rows(settings),
|
"settings": _setting_rows(settings),
|
||||||
"rulebooks": _rulebook_rows(rulebooks),
|
"rulebooks": _rulebook_rows(rulebooks),
|
||||||
"rulebook_topics": _topic_rows(topics),
|
"rulebook_topics": _topic_rows(topics),
|
||||||
@@ -689,6 +893,7 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
"design_systems": _design_system_rows(design_systems),
|
"design_systems": _design_system_rows(design_systems),
|
||||||
"design_tokens": _design_token_rows(design_tokens),
|
"design_tokens": _design_token_rows(design_tokens),
|
||||||
"note_usage_events": _usage_event_rows(usage_events),
|
"note_usage_events": _usage_event_rows(usage_events),
|
||||||
|
"rule_usage_events": _rule_usage_event_rows(rule_usage_events),
|
||||||
"repo_bindings": _repo_binding_rows(repo_bindings),
|
"repo_bindings": _repo_binding_rows(repo_bindings),
|
||||||
"note_supersessions": _note_supersession_rows(supersessions),
|
"note_supersessions": _note_supersession_rows(supersessions),
|
||||||
"code_shapes": _code_shape_rows(code_shapes),
|
"code_shapes": _code_shape_rows(code_shapes),
|
||||||
@@ -747,11 +952,29 @@ async def _restore_v1(data: dict) -> dict:
|
|||||||
body=n_data.get("body", ""),
|
body=n_data.get("body", ""),
|
||||||
tags=n_data.get("tags", []),
|
tags=n_data.get("tags", []),
|
||||||
parent_id=None, # patched below
|
parent_id=None, # patched below
|
||||||
|
arose_from_id=None, # patched below, same reason
|
||||||
|
description=n_data.get("description"),
|
||||||
|
note_type=n_data.get("note_type") or "note",
|
||||||
|
task_kind=n_data.get("task_kind") or "work",
|
||||||
|
data=n_data.get("data"),
|
||||||
|
started_at=_dt_or_none(n_data.get("started_at")),
|
||||||
|
completed_at=_dt_or_none(n_data.get("completed_at")),
|
||||||
|
recurrence_rule=n_data.get("recurrence_rule"),
|
||||||
|
recurrence_next_spawn_at=_dt_or_none(
|
||||||
|
n_data.get("recurrence_next_spawn_at")
|
||||||
|
),
|
||||||
status=n_data.get("status"),
|
status=n_data.get("status"),
|
||||||
priority=n_data.get("priority"),
|
priority=n_data.get("priority"),
|
||||||
due_date=_d(n_data.get("due_date")),
|
due_date=_d(n_data.get("due_date")),
|
||||||
created_at=_dt(n_data.get("created_at")),
|
created_at=_dt(n_data.get("created_at")),
|
||||||
updated_at=_dt(n_data.get("updated_at")),
|
updated_at=_dt(n_data.get("updated_at")),
|
||||||
|
verify_with=n_data.get("verify_with"),
|
||||||
|
expires_when=n_data.get("expires_when"),
|
||||||
|
# _dt_or_none, NOT _dt: an absent stamp must stay absent. _dt
|
||||||
|
# substitutes now(), which would restore every never-checked
|
||||||
|
# note as checked at the moment of the restore — inverting the
|
||||||
|
# one signal the sweep reads.
|
||||||
|
verified_at=_dt_or_none(n_data.get("verified_at")),
|
||||||
)
|
)
|
||||||
session.add(note)
|
session.add(note)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -759,14 +982,25 @@ async def _restore_v1(data: dict) -> dict:
|
|||||||
note_id_map[old_id] = note.id
|
note_id_map[old_id] = note.id
|
||||||
stats["notes"] += 1
|
stats["notes"] += 1
|
||||||
|
|
||||||
# Patch parent_id now that all notes have new IDs
|
# Patch the two note->note edges now that every note has a new id.
|
||||||
|
# Both are ids in the SOURCE database, so writing either straight into
|
||||||
|
# the constructor would point at whatever record happens to hold that
|
||||||
|
# number here — a restore that succeeds and silently re-parents (#3182).
|
||||||
|
# An edge whose target did not survive the import is left NULL rather
|
||||||
|
# than guessed at.
|
||||||
for n_data in data.get("notes", []):
|
for n_data in data.get("notes", []):
|
||||||
old_id = n_data.get("id")
|
old_id = n_data.get("id")
|
||||||
|
if not old_id or old_id not in note_id_map:
|
||||||
|
continue
|
||||||
|
note_row = await session.get(Note, note_id_map[old_id])
|
||||||
|
if note_row is None:
|
||||||
|
continue
|
||||||
old_parent = n_data.get("parent_id")
|
old_parent = n_data.get("parent_id")
|
||||||
if old_id and old_parent and old_id in note_id_map and old_parent in note_id_map:
|
if old_parent and old_parent in note_id_map:
|
||||||
note_row = await session.get(Note, note_id_map[old_id])
|
note_row.parent_id = note_id_map[old_parent]
|
||||||
if note_row:
|
old_origin = n_data.get("arose_from_id")
|
||||||
note_row.parent_id = note_id_map[old_parent]
|
if old_origin and old_origin in note_id_map:
|
||||||
|
note_row.arose_from_id = note_id_map[old_origin]
|
||||||
|
|
||||||
for s_data in data.get("settings", []):
|
for s_data in data.get("settings", []):
|
||||||
mapped_user_id = user_id_map.get(s_data.get("user_id", 0))
|
mapped_user_id = user_id_map.get(s_data.get("user_id", 0))
|
||||||
@@ -796,10 +1030,11 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
"rulebook_subscriptions": 0, "rule_suppressions": 0,
|
"rulebook_subscriptions": 0, "rule_suppressions": 0,
|
||||||
"topic_suppressions": 0, "rulebook_exclusions": 0,
|
"topic_suppressions": 0, "rulebook_exclusions": 0,
|
||||||
"systems": 0, "record_systems": 0, "design_systems": 0,
|
"systems": 0, "record_systems": 0, "design_systems": 0,
|
||||||
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
|
"design_tokens": 0, "note_usage_events": 0, "rule_usage_events": 0,
|
||||||
|
"repo_bindings": 0,
|
||||||
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
|
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
|
||||||
"code_shape_uses": 0, "canonical_systems": 0,
|
"code_shape_uses": 0, "canonical_systems": 0,
|
||||||
"rule_systems": 0, "rule_relations": 0,
|
"rule_systems": 0, "rule_relations": 0, "rule_versions": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
@@ -859,6 +1094,7 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
project_id=mapped_pid,
|
project_id=mapped_pid,
|
||||||
title=m_data.get("title", ""),
|
title=m_data.get("title", ""),
|
||||||
description=m_data.get("description"),
|
description=m_data.get("description"),
|
||||||
|
body=m_data.get("body"),
|
||||||
status=m_data.get("status", "active"),
|
status=m_data.get("status", "active"),
|
||||||
order_index=m_data.get("order_index", 0),
|
order_index=m_data.get("order_index", 0),
|
||||||
created_at=_dt(m_data.get("created_at")),
|
created_at=_dt(m_data.get("created_at")),
|
||||||
@@ -871,6 +1107,7 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
|
|
||||||
# 4a. Notes — first pass (no parent_id yet)
|
# 4a. Notes — first pass (no parent_id yet)
|
||||||
notes_with_parents: list[tuple[int, int]] = [] # (new_note_id, old_parent_id)
|
notes_with_parents: list[tuple[int, int]] = [] # (new_note_id, old_parent_id)
|
||||||
|
notes_with_origins: list[tuple[int, int]] = [] # (new_note_id, old_arose_from_id)
|
||||||
for n_data in data.get("notes", []):
|
for n_data in data.get("notes", []):
|
||||||
mapped_uid = user_id_map.get(n_data.get("user_id", 0))
|
mapped_uid = user_id_map.get(n_data.get("user_id", 0))
|
||||||
if mapped_uid is None:
|
if mapped_uid is None:
|
||||||
@@ -881,6 +1118,17 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
body=n_data.get("body", ""),
|
body=n_data.get("body", ""),
|
||||||
tags=n_data.get("tags", []),
|
tags=n_data.get("tags", []),
|
||||||
parent_id=None,
|
parent_id=None,
|
||||||
|
arose_from_id=None, # patched below, same reason as parent_id
|
||||||
|
description=n_data.get("description"),
|
||||||
|
note_type=n_data.get("note_type") or "note",
|
||||||
|
task_kind=n_data.get("task_kind") or "work",
|
||||||
|
data=n_data.get("data"),
|
||||||
|
started_at=_dt_or_none(n_data.get("started_at")),
|
||||||
|
completed_at=_dt_or_none(n_data.get("completed_at")),
|
||||||
|
recurrence_rule=n_data.get("recurrence_rule"),
|
||||||
|
recurrence_next_spawn_at=_dt_or_none(
|
||||||
|
n_data.get("recurrence_next_spawn_at")
|
||||||
|
),
|
||||||
project_id=project_id_map.get(n_data["project_id"]) if n_data.get("project_id") else None,
|
project_id=project_id_map.get(n_data["project_id"]) if n_data.get("project_id") else None,
|
||||||
milestone_id=milestone_id_map.get(n_data["milestone_id"]) if n_data.get("milestone_id") else None,
|
milestone_id=milestone_id_map.get(n_data["milestone_id"]) if n_data.get("milestone_id") else None,
|
||||||
status=n_data.get("status"),
|
status=n_data.get("status"),
|
||||||
@@ -888,21 +1136,35 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
due_date=_d(n_data.get("due_date")),
|
due_date=_d(n_data.get("due_date")),
|
||||||
created_at=_dt(n_data.get("created_at")),
|
created_at=_dt(n_data.get("created_at")),
|
||||||
updated_at=_dt(n_data.get("updated_at")),
|
updated_at=_dt(n_data.get("updated_at")),
|
||||||
|
verify_with=n_data.get("verify_with"),
|
||||||
|
expires_when=n_data.get("expires_when"),
|
||||||
|
# _dt_or_none — see the note on the other restore path.
|
||||||
|
verified_at=_dt_or_none(n_data.get("verified_at")),
|
||||||
)
|
)
|
||||||
session.add(note)
|
session.add(note)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
note_id_map[n_data["id"]] = note.id
|
note_id_map[n_data["id"]] = note.id
|
||||||
if n_data.get("parent_id"):
|
if n_data.get("parent_id"):
|
||||||
notes_with_parents.append((note.id, n_data["parent_id"]))
|
notes_with_parents.append((note.id, n_data["parent_id"]))
|
||||||
|
if n_data.get("arose_from_id"):
|
||||||
|
notes_with_origins.append((note.id, n_data["arose_from_id"]))
|
||||||
stats["notes"] += 1
|
stats["notes"] += 1
|
||||||
|
|
||||||
# 4b. Patch parent_id
|
# 4b. Patch the note->note edges. Deferred for the same reason
|
||||||
|
# parent_id always has been: these are ids in the SOURCE database
|
||||||
|
# (#3182). An edge whose target did not survive stays NULL.
|
||||||
for new_note_id, old_parent_id in notes_with_parents:
|
for new_note_id, old_parent_id in notes_with_parents:
|
||||||
new_parent_id = note_id_map.get(old_parent_id)
|
new_parent_id = note_id_map.get(old_parent_id)
|
||||||
if new_parent_id:
|
if new_parent_id:
|
||||||
note_row = await session.get(Note, new_note_id)
|
note_row = await session.get(Note, new_note_id)
|
||||||
if note_row:
|
if note_row:
|
||||||
note_row.parent_id = new_parent_id
|
note_row.parent_id = new_parent_id
|
||||||
|
for new_note_id, old_origin_id in notes_with_origins:
|
||||||
|
new_origin_id = note_id_map.get(old_origin_id)
|
||||||
|
if new_origin_id:
|
||||||
|
note_row = await session.get(Note, new_note_id)
|
||||||
|
if note_row:
|
||||||
|
note_row.arose_from_id = new_origin_id
|
||||||
|
|
||||||
# 5. TaskLogs
|
# 5. TaskLogs
|
||||||
for tl_data in data.get("task_logs", []):
|
for tl_data in data.get("task_logs", []):
|
||||||
@@ -1141,6 +1403,32 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
))
|
))
|
||||||
stats["rule_relations"] += 1
|
stats["rule_relations"] += 1
|
||||||
|
|
||||||
|
# A rule's edit history (milestone 323). Must come after the rules
|
||||||
|
# themselves — rule_id_map is only populated above — and both ids are
|
||||||
|
# ids in the SOURCE database, which is #3182's arose_from_id trap.
|
||||||
|
for rv in data.get("rule_versions", []):
|
||||||
|
mapped_rid = rule_id_map.get(rv.get("rule_id", 0))
|
||||||
|
if mapped_rid is None:
|
||||||
|
continue
|
||||||
|
# Unlike NoteVersion, an unmappable user does NOT drop the row.
|
||||||
|
# user_id is the ACTOR and is nullable by design: the column is
|
||||||
|
# SET NULL precisely so history outlives the account that wrote
|
||||||
|
# it. Skipping here would delete the record the FK preserves.
|
||||||
|
session.add(RuleVersion(
|
||||||
|
rule_id=mapped_rid,
|
||||||
|
user_id=user_id_map.get(rv.get("user_id") or 0),
|
||||||
|
title=rv.get("title", ""),
|
||||||
|
statement=rv.get("statement", ""),
|
||||||
|
why=rv.get("why"),
|
||||||
|
how_to_apply=rv.get("how_to_apply"),
|
||||||
|
when_to_apply=rv.get("when_to_apply"),
|
||||||
|
tier=rv.get("tier"),
|
||||||
|
verify_with=rv.get("verify_with"),
|
||||||
|
expires_when=rv.get("expires_when"),
|
||||||
|
created_at=_dt(rv.get("created_at")),
|
||||||
|
))
|
||||||
|
stats["rule_versions"] += 1
|
||||||
|
|
||||||
# 15. Systems
|
# 15. Systems
|
||||||
for sy_data in data.get("systems", []):
|
for sy_data in data.get("systems", []):
|
||||||
mapped_uid = user_id_map.get(sy_data.get("user_id", 0))
|
mapped_uid = user_id_map.get(sy_data.get("user_id", 0))
|
||||||
@@ -1245,6 +1533,25 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
))
|
))
|
||||||
stats["note_usage_events"] += 1
|
stats["note_usage_events"] += 1
|
||||||
|
|
||||||
|
# The rule twin — and the reason it is a separate table at all.
|
||||||
|
# Resolved through rule_id_map, NOT note_id_map. A rule id run through
|
||||||
|
# the note map would either drop (best case) or land on whatever note
|
||||||
|
# took that number, producing telemetry that is wrong rather than
|
||||||
|
# missing and that nothing downstream could detect (milestone 333).
|
||||||
|
# Must come after the rules themselves; rule_id_map is populated there.
|
||||||
|
for ev in data.get("rule_usage_events", []):
|
||||||
|
mapped_rid = rule_id_map.get(ev.get("rule_id", 0))
|
||||||
|
if mapped_rid is None:
|
||||||
|
continue
|
||||||
|
session.add(RuleUsageEvent(
|
||||||
|
user_id=user_id_map.get(ev.get("user_id") or 0),
|
||||||
|
rule_id=mapped_rid,
|
||||||
|
event=ev.get("event", ""),
|
||||||
|
source=ev.get("source", ""),
|
||||||
|
created_at=_dt(ev.get("created_at")),
|
||||||
|
))
|
||||||
|
stats["rule_usage_events"] += 1
|
||||||
|
|
||||||
# 20. Repo bindings — small, but losing them means every bound repo
|
# 20. Repo bindings — small, but losing them means every bound repo
|
||||||
# quietly stops loading its project at session start.
|
# quietly stops loading its project at session start.
|
||||||
for rb_data in data.get("repo_bindings", []):
|
for rb_data in data.get("repo_bindings", []):
|
||||||
@@ -1255,6 +1562,7 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
session.add(RepoBinding(
|
session.add(RepoBinding(
|
||||||
user_id=mapped_uid, project_id=mapped_pid,
|
user_id=mapped_uid, project_id=mapped_pid,
|
||||||
repo_key=rb_data.get("repo_key", ""),
|
repo_key=rb_data.get("repo_key", ""),
|
||||||
|
ref=rb_data.get("ref"),
|
||||||
))
|
))
|
||||||
stats["repo_bindings"] += 1
|
stats["repo_bindings"] += 1
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,13 @@ def _open_order():
|
|||||||
|
|
||||||
|
|
||||||
def _task_row(n: Note) -> dict:
|
def _task_row(n: Note) -> dict:
|
||||||
|
# task_kind rides along so a dashboard row can show WHAT KIND of work it
|
||||||
|
# is, not just how it is going. Omitting it made the kind badge render
|
||||||
|
# nothing here while working everywhere else — the badge was correct and
|
||||||
|
# the payload was short, which reads as "no issues in this list" rather
|
||||||
|
# than as a missing field.
|
||||||
return {"id": n.id, "title": n.title, "status": n.status,
|
return {"id": n.id, "title": n.title, "status": n.status,
|
||||||
"priority": n.priority or "none"}
|
"priority": n.priority or "none", "task_kind": n.task_kind}
|
||||||
|
|
||||||
|
|
||||||
async def _safe(coro, empty):
|
async def _safe(coro, empty):
|
||||||
|
|||||||
@@ -344,6 +344,52 @@ def chunk_document(title: str | None, body: str | None) -> list[str]:
|
|||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
async def _claim_parent_row(session, id_column, row_id: int, label: str) -> bool:
|
||||||
|
"""Lock the record a vector belongs to BEFORE rewriting that vector (#3262).
|
||||||
|
|
||||||
|
An embedding write and a cascading delete of the same record take the same
|
||||||
|
two row locks in OPPOSITE orders. The embedder deletes the old chunk rows
|
||||||
|
and then, on INSERT, needs the foreign key's lock on the parent; a delete
|
||||||
|
of the parent — or of the rulebook, topic or project above it — locks the
|
||||||
|
parent first and cascades down into the chunk rows. That is a cycle, and
|
||||||
|
Postgres breaks it by killing one side at random: sometimes the embedding
|
||||||
|
write, which is swallowed and invisible, and sometimes the operator's
|
||||||
|
delete, which surfaces as a 500 on an operation that should have worked.
|
||||||
|
|
||||||
|
Claiming the parent first REMOVES the cycle rather than narrowing it.
|
||||||
|
Either the embedder arrives first and the delete waits its turn behind it,
|
||||||
|
or the delete already holds the row and NOWAIT makes the embedder lose at
|
||||||
|
once. The embedder is the side that should lose: a skipped refresh costs a
|
||||||
|
stale vector until the next write or the startup backfill, and the other
|
||||||
|
outcome costs a person their request.
|
||||||
|
|
||||||
|
FOR KEY SHARE, not FOR UPDATE — it is precisely the lock the INSERT's
|
||||||
|
foreign key would take anyway, so it conflicts with a delete of the parent
|
||||||
|
and with nothing else. An ordinary edit of the same record, or a second
|
||||||
|
refresh racing this one, is unaffected.
|
||||||
|
|
||||||
|
Returns False when the row is locked or already gone; the caller skips.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
held = (await session.execute(
|
||||||
|
select(id_column)
|
||||||
|
.where(id_column == row_id)
|
||||||
|
# BOTH flags: SQLAlchemy spells the four Postgres row locks as a
|
||||||
|
# read/key_share pair, and key_share alone is FOR NO KEY UPDATE —
|
||||||
|
# which would make two refreshes of one record fight each other.
|
||||||
|
.with_for_update(read=True, key_share=True, nowait=True)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
except Exception:
|
||||||
|
# LockNotAvailable: this record is being deleted right now. Not an
|
||||||
|
# error — the delete wins by design.
|
||||||
|
logger.debug("Skipping embedding for %s %d — row is being deleted", label, row_id)
|
||||||
|
return False
|
||||||
|
if held is None:
|
||||||
|
logger.debug("Skipping embedding for %s %d — row is gone", label, row_id)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def upsert_note_embedding(
|
async def upsert_note_embedding(
|
||||||
note_id: int, user_id: int, title: str | None, body: str | None
|
note_id: int, user_id: int, title: str | None, body: str | None
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -380,6 +426,8 @@ async def upsert_note_embedding(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
|
if not await _claim_parent_row(session, Note.id, note_id, "note"):
|
||||||
|
return
|
||||||
await session.execute(
|
await session.execute(
|
||||||
delete(NoteEmbedding).where(NoteEmbedding.note_id == note_id)
|
delete(NoteEmbedding).where(NoteEmbedding.note_id == note_id)
|
||||||
)
|
)
|
||||||
@@ -400,6 +448,23 @@ async def upsert_note_embedding(
|
|||||||
logger.warning("Failed to persist embedding for note %d", note_id, exc_info=True)
|
logger.warning("Failed to persist embedding for note %d", note_id, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Both searches rank WITHOUT the threshold and apply it in Python, so the best
|
||||||
|
# rejected score stays observable (#3670). The qualifying set is provably
|
||||||
|
# unchanged: rows arrive ordered by distance ascending, so every above-bar row
|
||||||
|
# sorts ahead of every below-bar one, and an over-fetch that used to return N
|
||||||
|
# above-bar rows returns the same N plus some losers. What changes is only that
|
||||||
|
# the losers are now visible instead of discarded inside the query.
|
||||||
|
#
|
||||||
|
# That visibility is the entire point. A bar can only be judged from the calls
|
||||||
|
# it TURNED AWAY — a 0.72 bar rejecting a stream of 0.71s is set too high by a
|
||||||
|
# hair, one rejecting 0.30s is working — and those two are indistinguishable
|
||||||
|
# from any arrangement of the columns that survive the filter.
|
||||||
|
#
|
||||||
|
# `report` is how the score gets out without changing what a search RETURNS.
|
||||||
|
# Eight of the eleven call sites want hits and nothing else; the three that
|
||||||
|
# write telemetry pass a dict and read `best_available_score` back out of it.
|
||||||
|
|
||||||
|
|
||||||
async def semantic_search_notes(
|
async def semantic_search_notes(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
query: str,
|
query: str,
|
||||||
@@ -414,12 +479,19 @@ async def semantic_search_notes(
|
|||||||
scope: str = "own",
|
scope: str = "own",
|
||||||
demote_superseded: bool = True,
|
demote_superseded: bool = True,
|
||||||
system_id: int | None = None,
|
system_id: int | None = None,
|
||||||
|
report: dict | None = None,
|
||||||
) -> list[tuple[float, Note]]:
|
) -> list[tuple[float, Note]]:
|
||||||
"""Return up to *limit* (score, note) pairs most relevant to *query*.
|
"""Return up to *limit* (score, note) pairs most relevant to *query*.
|
||||||
|
|
||||||
Scores are cosine similarities in [-1, 1]; only notes at or above
|
Scores are cosine similarities in [-1, 1]; only notes at or above
|
||||||
*threshold* are returned, sorted highest-first.
|
*threshold* are returned, sorted highest-first.
|
||||||
|
|
||||||
|
Pass `report` (an empty dict) to learn what the threshold turned away:
|
||||||
|
the function sets `report["best_available_score"]` to the highest score
|
||||||
|
anything reached, or None when the corpus offered nothing at all. It is
|
||||||
|
the only figure that survives a call returning nothing, and therefore the
|
||||||
|
only one a bar can be judged from (#3670).
|
||||||
|
|
||||||
`note_type` narrows to a record kind, or several (e.g. "snippet", or
|
`note_type` narrows to a record kind, or several (e.g. "snippet", or
|
||||||
("snippet", "note")), for callers that want prior art rather than everything
|
("snippet", "note")), for callers that want prior art rather than everything
|
||||||
embedded.
|
embedded.
|
||||||
@@ -465,7 +537,6 @@ async def semantic_search_notes(
|
|||||||
# Distance ceiling equivalent to the similarity floor. Clamp to the valid
|
# Distance ceiling equivalent to the similarity floor. Clamp to the valid
|
||||||
# cosine-distance range [0, 2] so a threshold of, say, -1 doesn't produce a
|
# cosine-distance range [0, 2] so a threshold of, say, -1 doesn't produce a
|
||||||
# nonsensical ceiling.
|
# nonsensical ceiling.
|
||||||
max_distance = min(2.0, max(0.0, 1.0 - threshold))
|
|
||||||
distance = NoteEmbedding.embedding.cosine_distance(query_vec)
|
distance = NoteEmbedding.embedding.cosine_distance(query_vec)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -540,11 +611,10 @@ async def semantic_search_notes(
|
|||||||
fetch = limit * _CHUNK_OVERFETCH * (
|
fetch = limit * _CHUNK_OVERFETCH * (
|
||||||
_SUPERSESSION_OVERFETCH if demote_superseded else 1
|
_SUPERSESSION_OVERFETCH if demote_superseded else 1
|
||||||
)
|
)
|
||||||
stmt = (
|
# NO threshold predicate — see the note above this function. The
|
||||||
stmt.where(distance <= max_distance)
|
# bar is applied after the collapse, where the rejected scores can
|
||||||
.order_by(distance.asc())
|
# still be seen.
|
||||||
.limit(fetch)
|
stmt = stmt.order_by(distance.asc()).limit(fetch)
|
||||||
)
|
|
||||||
rows = list((await session.execute(stmt)).all())
|
rows = list((await session.execute(stmt)).all())
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to query note embeddings", exc_info=True)
|
logger.warning("Failed to query note embeddings", exc_info=True)
|
||||||
@@ -563,6 +633,11 @@ async def semantic_search_notes(
|
|||||||
continue
|
continue
|
||||||
seen.add(int(note.id))
|
seen.add(int(note.id))
|
||||||
scored.append((1.0 - float(dist), note))
|
scored.append((1.0 - float(dist), note))
|
||||||
|
# The best score anything reached, bar or no bar. Recorded BEFORE the
|
||||||
|
# filter because a call that returns nothing is exactly when it matters.
|
||||||
|
if report is not None:
|
||||||
|
report["best_available_score"] = scored[0][0] if scored else None
|
||||||
|
scored = [pair for pair in scored if pair[0] >= threshold]
|
||||||
if not demote_superseded:
|
if not demote_superseded:
|
||||||
return scored[:limit]
|
return scored[:limit]
|
||||||
return await _apply_supersession_penalty(scored, limit)
|
return await _apply_supersession_penalty(scored, limit)
|
||||||
@@ -666,6 +741,8 @@ async def upsert_rule_embedding(
|
|||||||
replacement is atomic per rule so a concurrent read sees the old chunk set
|
replacement is atomic per rule so a concurrent read sees the old chunk set
|
||||||
or the new one, never a mixture.
|
or the new one, never a mixture.
|
||||||
"""
|
"""
|
||||||
|
from scribe.models.rulebook import Rule # runtime import: see TYPE_CHECKING above
|
||||||
|
|
||||||
doc_title, doc_body = rule_document(title, statement, when_to_apply)
|
doc_title, doc_body = rule_document(title, statement, when_to_apply)
|
||||||
chunks = chunk_document(doc_title, doc_body)
|
chunks = chunk_document(doc_title, doc_body)
|
||||||
try:
|
try:
|
||||||
@@ -688,6 +765,8 @@ async def upsert_rule_embedding(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
|
if not await _claim_parent_row(session, Rule.id, rule_id, "rule"):
|
||||||
|
return
|
||||||
await session.execute(
|
await session.execute(
|
||||||
delete(RuleEmbedding).where(RuleEmbedding.rule_id == rule_id)
|
delete(RuleEmbedding).where(RuleEmbedding.rule_id == rule_id)
|
||||||
)
|
)
|
||||||
@@ -712,9 +791,16 @@ async def semantic_search_rules(
|
|||||||
limit: int = 5,
|
limit: int = 5,
|
||||||
threshold: float = _SIMILARITY_THRESHOLD,
|
threshold: float = _SIMILARITY_THRESHOLD,
|
||||||
tier: str | None = None,
|
tier: str | None = None,
|
||||||
|
report: dict | None = None,
|
||||||
) -> list[tuple[float, "Rule"]]:
|
) -> list[tuple[float, "Rule"]]:
|
||||||
"""Return up to *limit* (score, rule) pairs most relevant to *query*.
|
"""Return up to *limit* (score, rule) pairs most relevant to *query*.
|
||||||
|
|
||||||
|
Pass `report` (an empty dict) to learn what the threshold turned away:
|
||||||
|
the function sets `report["best_available_score"]` to the highest score
|
||||||
|
anything reached, or None when the corpus offered nothing at all. It is
|
||||||
|
the only figure that survives a call returning nothing, and therefore the
|
||||||
|
only one a bar can be judged from (#3670).
|
||||||
|
|
||||||
Scoped by OWNERSHIP — a rule is the caller's if they own its rulebook or
|
Scoped by OWNERSHIP — a rule is the caller's if they own its rulebook or
|
||||||
its project. Deliberately not filtered to what currently BINDS a given
|
its project. Deliberately not filtered to what currently BINDS a given
|
||||||
project: this answers "is there a rule about this", which a person asking
|
project: this answers "is there a rule about this", which a person asking
|
||||||
@@ -722,10 +808,17 @@ async def semantic_search_rules(
|
|||||||
is the surfacing question, and it has its own machinery
|
is the surfacing question, and it has its own machinery
|
||||||
(get_applicable_rules) rather than a second, subtly different copy here.
|
(get_applicable_rules) rather than a second, subtly different copy here.
|
||||||
|
|
||||||
`tier` narrows to one tier. The write-path hint passes "conditional",
|
`tier` narrows to one tier, and NONE is the ordinary case. The write-path
|
||||||
because an always-on rule is ALREADY in the session — surfacing it again as
|
and pre-tool hints deliberately pass nothing: an always-on rule is already
|
||||||
a suggestion is pure noise, and noise on a hint that fires on every write
|
in the session, but being in a list from turn zero is not the same as being
|
||||||
is how a hint gets ignored.
|
in front of the reader when the action it governs is taken, and filtering
|
||||||
|
on tier made a whole class of rules permanently ineligible for the one
|
||||||
|
mechanism that surfaces a rule AT the moment. Relevance is the threshold's
|
||||||
|
job; see the block above RULEHINT_LIMIT in services/plugin_context.py for
|
||||||
|
the argument and for what the resulting scores are being read against.
|
||||||
|
|
||||||
|
Pass a tier when a caller genuinely wants one class — a listing, an audit,
|
||||||
|
a UI that renders the tiers apart. Not to approximate relevance.
|
||||||
|
|
||||||
Collapses to best-chunk-per-rule like the note search, so a long rule split
|
Collapses to best-chunk-per-rule like the note search, so a long rule split
|
||||||
across chunks competes once rather than crowding the results with itself.
|
across chunks competes once rather than crowding the results with itself.
|
||||||
@@ -743,7 +836,6 @@ async def semantic_search_rules(
|
|||||||
logger.debug("Rule search skipped — embedder unavailable")
|
logger.debug("Rule search skipped — embedder unavailable")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
max_distance = min(2.0, max(0.0, 1.0 - threshold))
|
|
||||||
distance = RuleEmbedding.embedding.cosine_distance(query_vec)
|
distance = RuleEmbedding.embedding.cosine_distance(query_vec)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -757,7 +849,8 @@ async def semantic_search_rules(
|
|||||||
.outerjoin(Project, Rule.project_id == Project.id)
|
.outerjoin(Project, Rule.project_id == Project.id)
|
||||||
.where(
|
.where(
|
||||||
Rule.deleted_at.is_(None),
|
Rule.deleted_at.is_(None),
|
||||||
distance <= max_distance,
|
# No threshold predicate — see the note above
|
||||||
|
# semantic_search_notes. Applied below, after the collapse.
|
||||||
# topic_id XOR project_id, so exactly one arm can match.
|
# topic_id XOR project_id, so exactly one arm can match.
|
||||||
or_(
|
or_(
|
||||||
Rulebook.owner_user_id == user_id,
|
Rulebook.owner_user_id == user_id,
|
||||||
@@ -780,7 +873,9 @@ async def semantic_search_rules(
|
|||||||
if rule.id not in best or score > best[rule.id][0]:
|
if rule.id not in best or score > best[rule.id][0]:
|
||||||
best[rule.id] = (score, rule)
|
best[rule.id] = (score, rule)
|
||||||
ranked = sorted(best.values(), key=lambda pair: pair[0], reverse=True)
|
ranked = sorted(best.values(), key=lambda pair: pair[0], reverse=True)
|
||||||
return ranked[:limit]
|
if report is not None:
|
||||||
|
report["best_available_score"] = ranked[0][0] if ranked else None
|
||||||
|
return [pair for pair in ranked if pair[0] >= threshold][:limit]
|
||||||
|
|
||||||
|
|
||||||
async def backfill_rule_embeddings() -> None:
|
async def backfill_rule_embeddings() -> None:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Knowledge service — unified query across notes, tasks, plans, and processes.
|
"""Knowledge service — one query across every record kind Scribe holds.
|
||||||
|
|
||||||
ACL (rules #47/#78, decision note 2094): these queries were owner-only until
|
ACL (rules #47/#78, decision note 2094): these queries were owner-only until
|
||||||
2026-07-25, which meant a record shared with you could be opened by id but never
|
2026-07-25, which meant a record shared with you could be opened by id but never
|
||||||
@@ -265,22 +265,94 @@ def _note_to_item(note: Note) -> dict:
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
def _apply_type_filter(stmt, note_type: str | None):
|
# What each type facet MEANS, once, for every arm that has to know.
|
||||||
"""Apply the type facet to a Note select.
|
#
|
||||||
|
# The vocabulary spans BOTH typing axes — `note_type` for non-task records and
|
||||||
|
# `task_kind` for tasks — so a facet cannot be a filter on one column, which is
|
||||||
|
# why this is a table rather than a chain of ifs. Each entry is
|
||||||
|
# (is_task, the value pinned on that axis); None pins nothing, i.e. every task.
|
||||||
|
#
|
||||||
|
# It is a table because the alternative had already gone wrong. The predicate
|
||||||
|
# was written three times — a SQL if-chain, a Python if-chain over semantic
|
||||||
|
# candidates, and a ternary computing the `is_task` pre-filter — and the three
|
||||||
|
# only agreed by luck. Adding `issue` to the SQL arm alone (the obvious edit,
|
||||||
|
# and the one #3128 was about to make) would have set the pre-filter to
|
||||||
|
# is_task=False, handed the Python arm a candidate set containing no tasks at
|
||||||
|
# all, and returned an empty semantic half for the Issues facet forever, with
|
||||||
|
# nothing red anywhere. A new facet is now one row here.
|
||||||
|
#
|
||||||
|
# `plan` is retired (0066) but kept: 90 legacy plan-tasks exist and a facet
|
||||||
|
# they answer to costs one line. It simply has no chip in the UI any more.
|
||||||
|
_FACETS: dict[str, tuple[bool, str | None]] = {
|
||||||
|
"task": (True, None),
|
||||||
|
"work": (True, "work"),
|
||||||
|
"issue": (True, "issue"),
|
||||||
|
"spike": (True, "spike"),
|
||||||
|
"plan": (True, "plan"),
|
||||||
|
"note": (False, "note"),
|
||||||
|
"process": (False, "process"),
|
||||||
|
"snippet": (False, "snippet"),
|
||||||
|
}
|
||||||
|
|
||||||
'task' = any task (status not null); 'plan' = a task with task_kind='plan';
|
# The non-task record types, for the counts query. Derived so it cannot drift
|
||||||
any other non-empty type = a non-task note of that note_type; None = all.
|
# from the table above.
|
||||||
|
NON_TASK_FACETS = tuple(
|
||||||
|
value for _is_task, value in _FACETS.values() if not _is_task and value
|
||||||
|
)
|
||||||
|
|
||||||
Trashed rows (deleted_at set) are always excluded.
|
# The whole vocabulary, for the door's request validation — public so the route
|
||||||
|
# validates against the same table the query reads instead of a hand-kept copy.
|
||||||
|
FACET_TYPES = frozenset(_FACETS)
|
||||||
|
|
||||||
|
|
||||||
|
# An unrecognised facet resolves to "a non-task note whose note_type is that
|
||||||
|
# string" — which matches nothing, since no row stores an unknown type. That is
|
||||||
|
# the behaviour the old if-chain had by falling through, and it is the right
|
||||||
|
# one: a typo should return an empty list, never the whole corpus.
|
||||||
|
def _facet(note_type: str) -> tuple[bool, str | None]:
|
||||||
|
return _FACETS.get(note_type, (False, note_type))
|
||||||
|
|
||||||
|
|
||||||
|
def facet_is_task(note_type: str | None) -> bool | None:
|
||||||
|
"""The `is_task` pre-filter a facet implies — None when it spans both.
|
||||||
|
|
||||||
|
Used to narrow the semantic candidate set before it is fetched. Reads the
|
||||||
|
same table `_apply_type_filter` and `matches_facet` read, so the pre-filter
|
||||||
|
can no longer disagree with the predicate it is meant to anticipate.
|
||||||
"""
|
"""
|
||||||
|
if not note_type:
|
||||||
|
return None
|
||||||
|
return _facet(note_type)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def matches_facet(note, note_type: str | None) -> bool:
|
||||||
|
"""The Python dialect of `_apply_type_filter`, for candidates the vector
|
||||||
|
search has already fetched — there is no query left to narrow.
|
||||||
|
|
||||||
|
Generated from the same table, so this is a translation rather than a
|
||||||
|
second implementation. Note the `not note.is_task` arm: the hand-written
|
||||||
|
version omitted it and was saved only by the upstream pre-filter.
|
||||||
|
"""
|
||||||
|
if not note_type:
|
||||||
|
return True
|
||||||
|
is_task, value = _facet(note_type)
|
||||||
|
if is_task:
|
||||||
|
return note.is_task and (value is None or note.task_kind == value)
|
||||||
|
return not note.is_task and note.note_type == value
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_type_filter(stmt, note_type: str | None):
|
||||||
|
"""Apply the type facet to a Note select. Trashed rows are always excluded."""
|
||||||
stmt = stmt.where(Note.deleted_at.is_(None))
|
stmt = stmt.where(Note.deleted_at.is_(None))
|
||||||
if note_type == "task":
|
if not note_type:
|
||||||
return stmt.where(Note.status.isnot(None))
|
return stmt
|
||||||
if note_type == "plan":
|
is_task, value = _facet(note_type)
|
||||||
return stmt.where(Note.status.isnot(None)).where(Note.task_kind == "plan")
|
if is_task:
|
||||||
if note_type:
|
stmt = stmt.where(Note.status.isnot(None))
|
||||||
return stmt.where(Note.note_type == note_type).where(Note.status.is_(None))
|
if value is not None:
|
||||||
return stmt
|
stmt = stmt.where(Note.task_kind == value)
|
||||||
|
return stmt
|
||||||
|
return stmt.where(Note.status.is_(None)).where(Note.note_type == value)
|
||||||
|
|
||||||
|
|
||||||
async def query_knowledge(
|
async def query_knowledge(
|
||||||
@@ -295,7 +367,7 @@ async def query_knowledge(
|
|||||||
locations: dict[str, str] | None = None,
|
locations: dict[str, str] | None = None,
|
||||||
verification: str = "",
|
verification: str = "",
|
||||||
) -> tuple[list[dict], int]:
|
) -> tuple[list[dict], int]:
|
||||||
"""Query knowledge objects (non-task notes) with filters.
|
"""Query knowledge objects with filters.
|
||||||
|
|
||||||
`project_id` narrows to one project (None = every project).
|
`project_id` narrows to one project (None = every project).
|
||||||
|
|
||||||
@@ -424,7 +496,7 @@ async def _semantic_knowledge_search(
|
|||||||
INTERACTIVE_SEARCH_THRESHOLD,
|
INTERACTIVE_SEARCH_THRESHOLD,
|
||||||
semantic_search_notes,
|
semantic_search_notes,
|
||||||
)
|
)
|
||||||
is_task_filter = True if note_type in ("task", "plan") else (False if note_type else None)
|
is_task_filter = facet_is_task(note_type)
|
||||||
import time as _time
|
import time as _time
|
||||||
_t0 = _time.perf_counter()
|
_t0 = _time.perf_counter()
|
||||||
candidates = await semantic_search_notes(
|
candidates = await semantic_search_notes(
|
||||||
@@ -454,11 +526,7 @@ async def _semantic_knowledge_search(
|
|||||||
for _score, note in candidates:
|
for _score, note in candidates:
|
||||||
if note.deleted_at is not None:
|
if note.deleted_at is not None:
|
||||||
continue
|
continue
|
||||||
if note_type == "task" and not note.is_task:
|
if not matches_facet(note, note_type):
|
||||||
continue
|
|
||||||
elif note_type == "plan" and (not note.is_task or note.task_kind != "plan"):
|
|
||||||
continue
|
|
||||||
elif note_type and note_type not in ("task", "plan") and note.note_type != note_type:
|
|
||||||
continue
|
continue
|
||||||
if tags and not all(t in (note.tags or []) for t in tags):
|
if tags and not all(t in (note.tags or []) for t in tags):
|
||||||
continue
|
continue
|
||||||
@@ -514,51 +582,40 @@ async def get_knowledge_counts(user_id: int, tags: list[str] | None = None) -> d
|
|||||||
search would surface."""
|
search would surface."""
|
||||||
visible = browsable_notes_clause(user_id)
|
visible = browsable_notes_clause(user_id)
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
# Count non-task types
|
def _scoped(stmt):
|
||||||
stmt = (
|
stmt = stmt.where(visible).where(Note.deleted_at.is_(None))
|
||||||
select(Note.note_type, func.count(Note.id))
|
for tag in tags or []:
|
||||||
.where(visible)
|
|
||||||
.where(Note.status.is_(None))
|
|
||||||
.where(Note.deleted_at.is_(None))
|
|
||||||
.where(Note.note_type.in_(["note", "process"]))
|
|
||||||
.group_by(Note.note_type)
|
|
||||||
)
|
|
||||||
if tags:
|
|
||||||
for tag in tags:
|
|
||||||
stmt = stmt.where(Note.tags.contains([tag]))
|
stmt = stmt.where(Note.tags.contains([tag]))
|
||||||
rows = list((await session.execute(stmt)).all())
|
return stmt
|
||||||
counts = {row[0]: row[1] for row in rows}
|
|
||||||
|
|
||||||
# Count tasks separately (is_task = status IS NOT NULL)
|
# One grouped query per typing axis. The task half used to be a count
|
||||||
task_stmt = (
|
# for 'task' plus a second count for 'plan', which is why 'issue' —
|
||||||
select(func.count(Note.id))
|
# 17% of every task here — had no number to show: each kind needed its
|
||||||
.where(visible)
|
# own query and nobody added one. Grouping by task_kind counts every
|
||||||
|
# kind, including ones added later, for the same two round-trips.
|
||||||
|
non_task = _scoped(
|
||||||
|
select(Note.note_type, func.count(Note.id))
|
||||||
|
.where(Note.status.is_(None))
|
||||||
|
.where(Note.note_type.in_(NON_TASK_FACETS))
|
||||||
|
).group_by(Note.note_type)
|
||||||
|
counts = {t: n for t, n in (await session.execute(non_task)).all()}
|
||||||
|
|
||||||
|
by_kind = _scoped(
|
||||||
|
select(Note.task_kind, func.count(Note.id))
|
||||||
.where(Note.status.isnot(None))
|
.where(Note.status.isnot(None))
|
||||||
.where(Note.deleted_at.is_(None))
|
).group_by(Note.task_kind)
|
||||||
)
|
kind_counts = {k: n for k, n in (await session.execute(by_kind)).all()}
|
||||||
if tags:
|
|
||||||
for tag in tags:
|
|
||||||
task_stmt = task_stmt.where(Note.tags.contains([tag]))
|
|
||||||
task_count: int = (await session.execute(task_stmt)).scalar_one()
|
|
||||||
counts["task"] = task_count
|
|
||||||
|
|
||||||
# Plans are a subset of tasks (task_kind='plan'); counted for the facet
|
# Kinds are SUBSETS of 'task' and are deliberately left out of the total —
|
||||||
# but NOT added to total to avoid double-counting against "task".
|
# adding them would count every task twice.
|
||||||
plan_stmt = (
|
counts["task"] = sum(kind_counts.values())
|
||||||
select(func.count(Note.id))
|
for kind, value in _FACETS.items():
|
||||||
.where(visible)
|
if value[0] and value[1] is not None:
|
||||||
.where(Note.status.isnot(None))
|
counts[kind] = kind_counts.get(kind, 0)
|
||||||
.where(Note.task_kind == "plan")
|
|
||||||
.where(Note.deleted_at.is_(None))
|
|
||||||
)
|
|
||||||
if tags:
|
|
||||||
for tag in tags:
|
|
||||||
plan_stmt = plan_stmt.where(Note.tags.contains([tag]))
|
|
||||||
counts["plan"] = (await session.execute(plan_stmt)).scalar_one()
|
|
||||||
|
|
||||||
for t in ("note", "task", "plan", "process"):
|
for t in NON_TASK_FACETS:
|
||||||
counts.setdefault(t, 0)
|
counts.setdefault(t, 0)
|
||||||
counts["total"] = sum(counts[t] for t in ("note", "task", "process"))
|
counts["total"] = counts["task"] + sum(counts[t] for t in NON_TASK_FACETS)
|
||||||
return counts
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
|
||||||
|
|
||||||
from sqlalchemy import case, func, select
|
from sqlalchemy import case, func, select
|
||||||
|
|
||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
|
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
|
||||||
from scribe.models.base import iso
|
from scribe.models.base import iso
|
||||||
|
from scribe.services.background import report_telemetry_failure
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -46,37 +46,19 @@ logger = logging.getLogger(__name__)
|
|||||||
# never lands. The done-callback discard keeps the set from growing.
|
# never lands. The done-callback discard keeps the set from growing.
|
||||||
_pending: set[asyncio.Task] = set()
|
_pending: set[asyncio.Task] = set()
|
||||||
|
|
||||||
# Sites that already dropped their once-per-process AppLog row. The readout
|
|
||||||
# runs on every snippet list render — without this, a broken table would turn
|
|
||||||
# the error log into a firehose that buries the finding it exists to surface.
|
|
||||||
_reported: set[str] = set()
|
|
||||||
|
|
||||||
|
|
||||||
async def _report_failure(site: str) -> None:
|
async def _report_failure(site: str) -> None:
|
||||||
"""Make a swallowed telemetry failure visible. Called from an except block.
|
"""This subsystem's canary, now the shared one.
|
||||||
|
|
||||||
WARNING to the process log every time; one AppLog error row per process per
|
The per-site dedup, the WARNING and the single AppLog row all moved to
|
||||||
site so the admin UI shows the outage without host access. The AppLog write
|
`background.report_telemetry_failure` unchanged when `rule_usage` needed
|
||||||
is itself guarded — when the whole database is down it fails too, and that
|
the identical behaviour — two hand-kept copies of a thing whose whole job
|
||||||
is fine: the WARNING already said so, and a canary must never take down the
|
is to be reliable is the wrong number. `retrieval_telemetry` deliberately
|
||||||
surface it watches.
|
still has its own: its canary is a different shape (one process-wide flag,
|
||||||
|
no AppLog row), so repointing it would change behaviour rather than
|
||||||
|
consolidate it.
|
||||||
"""
|
"""
|
||||||
logger.warning("note usage telemetry %s failed", site, exc_info=True)
|
await report_telemetry_failure("note_usage", site)
|
||||||
if site in _reported:
|
|
||||||
return
|
|
||||||
_reported.add(site)
|
|
||||||
try:
|
|
||||||
from scribe.services.logging import log_error
|
|
||||||
|
|
||||||
await log_error(
|
|
||||||
endpoint="note_usage",
|
|
||||||
error_type=f"note_usage_{site}_failed",
|
|
||||||
error_message=f"note usage telemetry {site} is failing; "
|
|
||||||
"usage counters will read zero until this is fixed",
|
|
||||||
traceback=traceback.format_exc(),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("note usage canary write failed", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def _insert_events(rows: list[dict]) -> None:
|
async def _insert_events(rows: list[dict]) -> None:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from collections.abc import Iterable
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import func, or_, select, text
|
from sqlalchemy import func, or_, select, text
|
||||||
@@ -9,6 +10,52 @@ from scribe.models.note import Note, TaskKind, TaskPriority, TaskStatus
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# The fields `snippets.parse_snippet_fields` reads. Writing any of them can
|
||||||
|
# change what a snippet's derived `data` mirror should say, so update_note
|
||||||
|
# recomposes the mirror when one moves. Kept here as a set of NAMES rather
|
||||||
|
# than imported, because it describes update_note's own `fields` dict, not the
|
||||||
|
# parser's signature.
|
||||||
|
_PARSED_FROM_BODY = frozenset({"title", "body", "tags"})
|
||||||
|
|
||||||
|
|
||||||
|
# Text fields where EMPTY MEANS NULL (milestone 317). The sweep's whole signal
|
||||||
|
# is `verify_with IS NULL` = "this is a decision, there is nothing to go and
|
||||||
|
# check". An empty string that is not NULL makes a norm look like a constraint
|
||||||
|
# nobody has verified, forever — and it would sit at the top of the sweep,
|
||||||
|
# since never-checked sorts first. Sibling of rulebooks.NULLABLE_RULE_TEXT.
|
||||||
|
NULLABLE_NOTE_TEXT = ("verify_with", "expires_when")
|
||||||
|
|
||||||
|
|
||||||
|
def guard_check_fields(status: str | None, note_type: str | None) -> None:
|
||||||
|
"""Raise unless a record in this shape may carry verify_with/expires_when.
|
||||||
|
|
||||||
|
Stated as an INVARIANT over the resulting record rather than a filter on
|
||||||
|
which fields a caller passed, so it also catches the sideways route: a
|
||||||
|
checked note being turned into a task, which no per-field gate would see.
|
||||||
|
|
||||||
|
Raises rather than dropping silently, for minted_kind's reason (#3129) — a
|
||||||
|
silently-corrected write is the defect that reasoning exists to end, and a
|
||||||
|
caller putting a check on the wrong record has an idea an error corrects
|
||||||
|
and a default hides. Lives at the service, not either door, so REST and MCP
|
||||||
|
cannot come to disagree about it.
|
||||||
|
"""
|
||||||
|
if status is not None:
|
||||||
|
raise ValueError(
|
||||||
|
"a task cannot carry verify_with/expires_when: a task's decay is "
|
||||||
|
"its status, and a done issue records what happened rather than "
|
||||||
|
"asserting something that can later go false. Put the check on the "
|
||||||
|
"note the fact lives in, or clear the check before making this a "
|
||||||
|
"task."
|
||||||
|
)
|
||||||
|
if note_type == "snippet":
|
||||||
|
raise ValueError(
|
||||||
|
"a snippet already has a check: verify_snippet(), which compares "
|
||||||
|
"the recorded location and code against the repo and expires its "
|
||||||
|
"own verdict when the code moves. verify_with/expires_when are the "
|
||||||
|
"free-text form, for prose notes that assert a fact about "
|
||||||
|
"something outside the repo."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def embed_note(note) -> None:
|
def embed_note(note) -> None:
|
||||||
"""Refresh a note's embedding, fire-and-forget.
|
"""Refresh a note's embedding, fire-and-forget.
|
||||||
@@ -29,6 +76,10 @@ def embed_note(note) -> None:
|
|||||||
exceptions are swallowed because a record that saved must not fail on its
|
exceptions are swallowed because a record that saved must not fail on its
|
||||||
index refresh. No running loop (unit tests, scripts) is an ordinary case,
|
index refresh. No running loop (unit tests, scripts) is an ordinary case,
|
||||||
not an error.
|
not an error.
|
||||||
|
|
||||||
|
Detaching also means this task races anything that deletes the note out
|
||||||
|
from under it. That is not handled here: `upsert_note_embedding` claims
|
||||||
|
the note's row before touching its vectors, and loses if it can't (#3262).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -101,7 +152,16 @@ async def create_note(
|
|||||||
task_kind: str = "work",
|
task_kind: str = "work",
|
||||||
arose_from_id: int | None = None,
|
arose_from_id: int | None = None,
|
||||||
data: dict | None = None,
|
data: dict | None = None,
|
||||||
|
verify_with: str | None = None,
|
||||||
|
expires_when: str | None = None,
|
||||||
) -> Note:
|
) -> Note:
|
||||||
|
# Empty means empty (NULLABLE_NOTE_TEXT), then the invariant. Both run
|
||||||
|
# before anything is written, so an illegal shape never reaches the table.
|
||||||
|
verify_with = verify_with or None
|
||||||
|
expires_when = expires_when or None
|
||||||
|
if verify_with or expires_when:
|
||||||
|
guard_check_fields(status, note_type)
|
||||||
|
|
||||||
# Validate status/priority here so the MCP create_task path (which passes
|
# Validate status/priority here so the MCP create_task path (which passes
|
||||||
# them straight through) can't persist an out-of-enum value that the REST
|
# them straight through) can't persist an out-of-enum value that the REST
|
||||||
# route would have rejected — there's no DB CHECK on notes.status.
|
# route would have rejected — there's no DB CHECK on notes.status.
|
||||||
@@ -145,6 +205,8 @@ async def create_note(
|
|||||||
task_kind=task_kind,
|
task_kind=task_kind,
|
||||||
arose_from_id=arose_from_id,
|
arose_from_id=arose_from_id,
|
||||||
data=data,
|
data=data,
|
||||||
|
verify_with=verify_with,
|
||||||
|
expires_when=expires_when,
|
||||||
)
|
)
|
||||||
session.add(note)
|
session.add(note)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -397,7 +459,20 @@ def minted_kind(kind: str) -> str:
|
|||||||
raise ValueError(f"kind must be one of {MINTABLE_KINDS}, got {kind!r}")
|
raise ValueError(f"kind must be one of {MINTABLE_KINDS}, got {kind!r}")
|
||||||
|
|
||||||
|
|
||||||
async def update_note(user_id: int, note_id: int, **fields: object) -> Note | None:
|
async def update_note(
|
||||||
|
user_id: int, note_id: int, clear: Iterable[str] = (), **fields: object,
|
||||||
|
) -> Note | None:
|
||||||
|
"""Partial update. `clear` names fields to UNSET; **fields carries values.
|
||||||
|
|
||||||
|
Clearing is explicit and separate because a nullable field cannot be
|
||||||
|
emptied by passing it: the MCP door reads "" as "leave this alone", so an
|
||||||
|
agent filling two fields does not wipe the others, and a note that stops
|
||||||
|
being a constraint genuinely needs its check removed. Naming the field is
|
||||||
|
the one form that cannot happen by accident. The REST door, where a
|
||||||
|
cleared form input arrives as "", reaches the same place through the
|
||||||
|
NULLABLE_NOTE_TEXT normalisation below — two idioms, one outcome.
|
||||||
|
(Same shape as rulebooks.update_rule, milestone 312 step 2.)
|
||||||
|
"""
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
select(Note).where(Note.id == note_id, Note.user_id == user_id)
|
||||||
@@ -409,6 +484,10 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
|
|||||||
old_body = note.body
|
old_body = note.body
|
||||||
old_title = note.title
|
old_title = note.title
|
||||||
old_tags = list(note.tags or [])
|
old_tags = list(note.tags or [])
|
||||||
|
check_before = note.verify_with
|
||||||
|
for key in clear:
|
||||||
|
if key in NULLABLE_NOTE_TEXT:
|
||||||
|
setattr(note, key, None)
|
||||||
for key, value in fields.items():
|
for key, value in fields.items():
|
||||||
if not hasattr(note, key):
|
if not hasattr(note, key):
|
||||||
continue
|
continue
|
||||||
@@ -438,7 +517,44 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
|
|||||||
)
|
)
|
||||||
elif key == "tags" and isinstance(value, list):
|
elif key == "tags" and isinstance(value, list):
|
||||||
value = _normalize_tags(value)
|
value = _normalize_tags(value)
|
||||||
|
elif key in NULLABLE_NOTE_TEXT:
|
||||||
|
value = value or None
|
||||||
|
elif key == "verified_at":
|
||||||
|
# Not settable here. A stamp says somebody performed THIS
|
||||||
|
# check, so it is written by the verification path and by a
|
||||||
|
# restore, never by an ordinary edit that could mint one for a
|
||||||
|
# check nobody ran.
|
||||||
|
continue
|
||||||
setattr(note, key, value)
|
setattr(note, key, value)
|
||||||
|
# The invariant, over the RESULTING record rather than over what was
|
||||||
|
# passed — which is what catches a checked note being turned into a
|
||||||
|
# task. Raised before commit, so nothing is persisted.
|
||||||
|
if note.verify_with or note.expires_when:
|
||||||
|
guard_check_fields(note.status, note.note_type)
|
||||||
|
# A stamp certifies A CHECK, not a record. Rewrite or remove the check
|
||||||
|
# and the old stamp certifies something that no longer exists, so it is
|
||||||
|
# dropped and the note re-enters the sweep. The safe direction: a note
|
||||||
|
# wrongly listed as due costs one look; a note wrongly vouched for
|
||||||
|
# costs exactly what the sweep exists to catch.
|
||||||
|
if note.verify_with != check_before:
|
||||||
|
note.verified_at = None
|
||||||
|
# A snippet's `data` is DERIVED from its body — so a write that moves
|
||||||
|
# the body through this generic door must move the mirror with it
|
||||||
|
# (#3128). Without this, PATCH /api/notes/<snippet_id> {body} left the
|
||||||
|
# mirror behind, and snippet_fields PREFERS the mirror: the row went on
|
||||||
|
# reporting its old repo/path/symbol to prior-art recall while showing
|
||||||
|
# its new body. `update_snippet` composes the mirror itself and passes
|
||||||
|
# it explicitly, so an explicit `data` always wins — the caller that
|
||||||
|
# knows the field set beats the one that can only re-read the body.
|
||||||
|
if "data" not in fields and not _PARSED_FROM_BODY.isdisjoint(fields):
|
||||||
|
# Imported here, not at module scope: services/snippets.py calls
|
||||||
|
# back into this module (update_snippet -> update_note), so a
|
||||||
|
# top-level import is a cycle.
|
||||||
|
from scribe.services.snippets import (
|
||||||
|
SNIPPET_NOTE_TYPE, recompose_data,
|
||||||
|
)
|
||||||
|
if note.note_type == SNIPPET_NOTE_TYPE:
|
||||||
|
note.data = recompose_data(note)
|
||||||
# Auto-set lifecycle timestamps on status transitions
|
# Auto-set lifecycle timestamps on status transitions
|
||||||
if "status" in fields:
|
if "status" in fields:
|
||||||
_now = datetime.now(timezone.utc)
|
_now = datetime.now(timezone.utc)
|
||||||
@@ -487,6 +603,154 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
|
|||||||
# permanent deletion. Both are reachable; neither is spelled `delete_note`.
|
# permanent deletion. Both are reachable; neither is spelled `delete_note`.
|
||||||
|
|
||||||
|
|
||||||
|
# ── The sweep (milestone 317 step 3) ─────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# A SIBLING of rulebooks.rules_due_for_verification, not a shared
|
||||||
|
# implementation, and deliberately so (note 3163). The row could have been
|
||||||
|
# shared; the QUERY cannot. That sweep scopes by rulebook ownership XOR project
|
||||||
|
# ownership because rules have no sharing ACL at all — no rule_shares, no
|
||||||
|
# can_read_rule. A note scopes by the note ACL, which is a different question
|
||||||
|
# with a different answer. What IS common — how a stamp reads, how old it is —
|
||||||
|
# lives in services/verification.py and is imported by both.
|
||||||
|
|
||||||
|
async def notes_due_for_verification(
|
||||||
|
user_id: int,
|
||||||
|
older_than_days: int = 0,
|
||||||
|
project_id: int | None = None,
|
||||||
|
never_only: bool = False,
|
||||||
|
) -> list[Note]:
|
||||||
|
"""Notes that carry a check, oldest verification first, never-checked top.
|
||||||
|
|
||||||
|
THE QUERY THE COLUMNS EXIST FOR. `verify_with` and `expires_when` are
|
||||||
|
storage; this is what turns them into something that gets acted on.
|
||||||
|
Without it, note decay is caught only when a human reads the note and
|
||||||
|
disagrees — which is the case where the note was already believed.
|
||||||
|
|
||||||
|
Ordered `verified_at` ASC **NULLS FIRST**: never-checked outranks
|
||||||
|
checked-long-ago, because a note nobody has ever confirmed is a claim with
|
||||||
|
no evidence behind it at all. Postgres sorts NULLs LAST on ASC by default,
|
||||||
|
so this is explicit — and getting it wrong would not error, it would
|
||||||
|
silently invert the one signal the sweep exists to carry.
|
||||||
|
|
||||||
|
Notes with no `verify_with` never appear. Not an omission: they are
|
||||||
|
decisions, there is nothing to go and check, and listing them would dilute
|
||||||
|
the result until nobody reads it.
|
||||||
|
|
||||||
|
Scoped with `browsable_notes_clause`, NOT the read scope (decision note
|
||||||
|
2094): a sweep is a passive surface, and a record shared one-to-one must
|
||||||
|
not arrive in one unasked.
|
||||||
|
|
||||||
|
Deliberately NOT filtered to non-task, non-snippet records even though the
|
||||||
|
write path (step 2) permits a check on nothing else. A row in that state
|
||||||
|
would be a row in an ILLEGAL state, and this is the one surface that could
|
||||||
|
tell somebody about it. Hiding it here to match the invariant would make
|
||||||
|
the sweep agree with a database it had stopped describing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: whose notes.
|
||||||
|
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. Negative raises: it would mean
|
||||||
|
"everything", which is a different question than the one asked,
|
||||||
|
answered silently.
|
||||||
|
project_id: narrow to one project. None = every project.
|
||||||
|
never_only: only notes that have never been verified.
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from scribe.services.access import browsable_notes_clause
|
||||||
|
|
||||||
|
if older_than_days < 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"older_than_days must be >= 0, got {older_than_days}. A negative "
|
||||||
|
f"window silently means 'everything', which is not what any caller "
|
||||||
|
f"of a staleness sweep is asking."
|
||||||
|
)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
# One statement, not a fetch-then-filter: the ordering below is the
|
||||||
|
# database's, so it cannot disagree with itself across two halves.
|
||||||
|
stmt = (
|
||||||
|
select(Note)
|
||||||
|
.where(
|
||||||
|
browsable_notes_clause(user_id),
|
||||||
|
Note.deleted_at.is_(None),
|
||||||
|
Note.verify_with.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if project_id is not None:
|
||||||
|
stmt = stmt.where(Note.project_id == project_id)
|
||||||
|
if never_only:
|
||||||
|
stmt = stmt.where(Note.verified_at.is_(None))
|
||||||
|
elif older_than_days > 0:
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(Note.verified_at.is_(None), Note.verified_at < cutoff)
|
||||||
|
)
|
||||||
|
stmt = stmt.order_by(Note.verified_at.asc().nullsfirst(), Note.id)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
def verification_row(note: Note) -> dict:
|
||||||
|
"""One row of the sweep — the CHECK in full, unlike a listing.
|
||||||
|
|
||||||
|
The opposite call from a browse: here the caller is about to go and run the
|
||||||
|
check, so the text they need IS the payload rather than the bloat.
|
||||||
|
"""
|
||||||
|
from scribe.services.verification import (
|
||||||
|
days_since_verified,
|
||||||
|
last_verified_label,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": note.id,
|
||||||
|
"title": note.title,
|
||||||
|
"project_id": note.project_id,
|
||||||
|
"verify_with": note.verify_with or "",
|
||||||
|
"expires_when": note.expires_when or "",
|
||||||
|
"last_verified": last_verified_label(note),
|
||||||
|
"days_since_verified": days_since_verified(note),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_note_verified(
|
||||||
|
note_id: int, user_id: int, still_true: bool = True,
|
||||||
|
) -> Note | None:
|
||||||
|
"""Stamp a note as verified — or, when the check FAILED, refuse to.
|
||||||
|
|
||||||
|
The asymmetry is the design: passing writes a stamp, failing writes
|
||||||
|
nothing. There is no "verified false" state, because a note whose check
|
||||||
|
failed is not a note in a special condition — it is a note that is WRONG,
|
||||||
|
and the honest resolutions are to correct it, supersede it, or find out
|
||||||
|
why. Recording the failure as a flag would let it sit there being false
|
||||||
|
with the sweep quietly satisfied that somebody had looked.
|
||||||
|
|
||||||
|
So a failed check leaves `verified_at` untouched and the note stays at the
|
||||||
|
top of the sweep until someone actually deals with it.
|
||||||
|
|
||||||
|
Write access, not read (rules 47/78): stamping is a mutation, and an
|
||||||
|
editor-share holder may make it while a viewer may not.
|
||||||
|
|
||||||
|
Returns None when the note is not found, not writable, or carries no
|
||||||
|
`verify_with` — nothing to verify is a different answer from verified.
|
||||||
|
"""
|
||||||
|
from scribe.services.access import can_write_note
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
note = (await session.execute(
|
||||||
|
select(Note).where(Note.id == note_id, Note.deleted_at.is_(None))
|
||||||
|
)).scalars().first()
|
||||||
|
if note is None or not note.verify_with:
|
||||||
|
return None
|
||||||
|
if not await can_write_note(user_id, note_id):
|
||||||
|
return None
|
||||||
|
if still_true:
|
||||||
|
note.verified_at = datetime.now(timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(note)
|
||||||
|
return note
|
||||||
|
|
||||||
|
|
||||||
async def get_all_tags(user_id: int, q: str | None = None) -> list[str]:
|
async def get_all_tags(user_id: int, q: str | None = None) -> list[str]:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
if q:
|
if q:
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ async def start_planning(user_id: int, project_id: int, title: str) -> dict:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"milestone": milestone.to_dict(),
|
"milestone": milestone.to_dict(),
|
||||||
**rulebooks_svc.rules_payload(applicable),
|
**rulebooks_svc.rules_payload(applicable, user_id=user_id, source="start_planning"),
|
||||||
"project_goal": getattr(project, "goal", "") or "",
|
"project_goal": getattr(project, "goal", "") or "",
|
||||||
"open_task_count": open_count,
|
"open_task_count": open_count,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from scribe.services import snippets as snippets_svc
|
|||||||
from scribe.services.access import label_shared_items, owner_names_for
|
from scribe.services.access import label_shared_items, owner_names_for
|
||||||
from scribe.services.embeddings import semantic_search_notes, semantic_search_rules
|
from scribe.services.embeddings import semantic_search_notes, semantic_search_rules
|
||||||
from scribe.services.note_usage import record_surfaced
|
from scribe.services.note_usage import record_surfaced
|
||||||
|
from scribe.services.rule_usage import record_rule_surfaced
|
||||||
from scribe.services.supersession import superseded_ids
|
from scribe.services.supersession import superseded_ids
|
||||||
from scribe.services.retrieval_telemetry import record_retrieval
|
from scribe.services.retrieval_telemetry import record_retrieval
|
||||||
from scribe.services.settings import get_setting
|
from scribe.services.settings import get_setting
|
||||||
@@ -85,6 +86,117 @@ WRITEPATH_THRESHOLD_KEY = "kb_writepath_threshold"
|
|||||||
WRITEPATH_DEFAULT_ENABLED = True
|
WRITEPATH_DEFAULT_ENABLED = True
|
||||||
WRITEPATH_DEFAULT_THRESHOLD = 0.68
|
WRITEPATH_DEFAULT_THRESHOLD = 0.68
|
||||||
|
|
||||||
|
# The standing-rule arm (milestone 307) gets its own bar — the split #2223 made
|
||||||
|
# one surface down, now made for the THIRD corpus. It inherited 0.68 above, and
|
||||||
|
# that number was measured against code-vs-note-PROSE. It was never re-derived
|
||||||
|
# for code-vs-RULE-TEXT.
|
||||||
|
#
|
||||||
|
# THE STRUCTURAL ARGUMENT, which is the only kind admissible here (rule 115).
|
||||||
|
# Two facts hold on any install, including one with six rules and no telemetry:
|
||||||
|
#
|
||||||
|
# 1. The eligible corpus is SMALL — every rule an install owns, still only
|
||||||
|
# a few dozen documents against thousands of notes. A top-k over a small
|
||||||
|
# pool always returns something, so "the best match cleared the bar"
|
||||||
|
# drifts from "a good match exists" toward "N things were ranked". A bar
|
||||||
|
# calibrated for best-of-thousands is cleared by best-of-forty as
|
||||||
|
# arithmetic rather than relevance.
|
||||||
|
# This argument WEAKENED when the arms stopped filtering to one tier
|
||||||
|
# (see the note on that below): a larger pool makes clearing the bar
|
||||||
|
# mean more, not less. The threshold was deliberately left where it was
|
||||||
|
# anyway — moving two variables at once would make the resulting
|
||||||
|
# distribution unreadable, and this one errs toward silence on purpose.
|
||||||
|
# 2. Rules are short imperative technical English — a far more HOMOGENEOUS
|
||||||
|
# corpus than note prose. #2223 measured the floor for code against prose
|
||||||
|
# at 0.55-0.63 and set 0.68 above it. A more homogeneous corpus has a
|
||||||
|
# HIGHER floor, so 0.68 is not merely inherited, it is below where this
|
||||||
|
# corpus's noise sits.
|
||||||
|
#
|
||||||
|
# WHY 0.72 AND NOT A NUMBER OFF A HISTOGRAM. The exact offset between prose's
|
||||||
|
# floor and rule-text's is not derivable in general — it depends on how an
|
||||||
|
# install writes its rules — so the default errs deliberately toward SILENCE
|
||||||
|
# rather than toward recall, on an asymmetry that is itself structural: this
|
||||||
|
# hint fires on EVERY write. A missed rule is recoverable, because the rule is
|
||||||
|
# still in Scribe and the agent can search it. A hint that cries wolf is not:
|
||||||
|
# it teaches the reader to skip the whole block, and the surface is lost along
|
||||||
|
# with the true positives it would have carried. The arm's own comment already
|
||||||
|
# says "noise on a hint that fires on every write is how a hint gets ignored".
|
||||||
|
#
|
||||||
|
# TUNE IT FROM YOUR OWN INSTANCE, which is now possible: `retrieval_telemetry`
|
||||||
|
# reports `rule_usage.pull_through` (milestone 333 step 3). Raise this if rules
|
||||||
|
# arrive unread; lower it if rules you needed never arrived. What would RETIRE
|
||||||
|
# it: a cross-encoder rerank (#1038), which would make a similarity bar the
|
||||||
|
# wrong control entirely.
|
||||||
|
RULEHINT_THRESHOLD_KEY = "kb_rulehint_threshold"
|
||||||
|
RULEHINT_DEFAULT_THRESHOLD = 0.72
|
||||||
|
|
||||||
|
# ONE rule per write, not two — and this is deliberately NOT a knob.
|
||||||
|
#
|
||||||
|
# With a corpus this small, top-k does as much damage as the threshold: k=2
|
||||||
|
# over a few dozen candidates means the second line is almost always the
|
||||||
|
# second-best noise, arriving with the same confident framing as the first.
|
||||||
|
# Halving k halves that regardless of where the bar sits.
|
||||||
|
#
|
||||||
|
# It also BOUNDS the blast radius of widening the pool (below): with k=1 a
|
||||||
|
# wider corpus can change WHICH rule surfaces and how often one does, but it
|
||||||
|
# can never make a single hint longer. The loudness of one hint and the
|
||||||
|
# eligibility of a rule are separate controls, and only one of them moved.
|
||||||
|
#
|
||||||
|
# It stays a constant because it is a decision about how LOUD one hint may be,
|
||||||
|
# not a per-install tuning question. The hint already carries prior art, shape
|
||||||
|
# signals and staleness; rules are the fourth voice in it, and a fourth voice
|
||||||
|
# that speaks twice is where a reader stops reading. Nothing suggests an
|
||||||
|
# operator wants this different, and a knob nobody turns is a knob that only
|
||||||
|
# adds a way to misconfigure the surface (rule 25 cuts both ways).
|
||||||
|
RULEHINT_LIMIT = 1
|
||||||
|
|
||||||
|
# WHY THE ARMS NO LONGER FILTER TO ONE TIER (#3702).
|
||||||
|
#
|
||||||
|
# Both arms used to pass `tier="conditional"`, on the reasoning that an
|
||||||
|
# always-on rule is already in the session, so surfacing it again is pure
|
||||||
|
# noise. That reasoning conflates two different things:
|
||||||
|
#
|
||||||
|
# PRESENT IN CONTEXT — the rule was delivered at session start.
|
||||||
|
# SALIENT AT THE MOMENT — the rule is in front of the reader when the
|
||||||
|
# action it governs is about to be taken.
|
||||||
|
#
|
||||||
|
# A rule handed over in a list at turn zero is present while a session writes
|
||||||
|
# a config value three hundred turns later. It is not surfaced. So the filter
|
||||||
|
# did not merely skip a redundant hint — it made a whole class of rules
|
||||||
|
# permanently ineligible for the only mechanism that puts a rule in front of
|
||||||
|
# an agent AT the moment, and the more important a rule is, the more likely
|
||||||
|
# it was in that class.
|
||||||
|
#
|
||||||
|
# The deeper defect is that the filter was doing the THRESHOLD's job. Whether
|
||||||
|
# a rule belongs in this hint is a relevance question, and a similarity bar is
|
||||||
|
# the control for relevance. A categorical exclusion standing in for a
|
||||||
|
# relevance judgment cannot be tuned, cannot be measured, and cannot be wrong
|
||||||
|
# in a way anybody notices.
|
||||||
|
#
|
||||||
|
# THIS IS A MEASURED CHANGE, NOT A SETTLED ONE. The old comment's fear is
|
||||||
|
# real — a hint that fires on every write and says obvious things teaches the
|
||||||
|
# reader to skip the block, and the surface is then lost along with its true
|
||||||
|
# positives. That fear had simply never been checked. `retrieval_logs` already
|
||||||
|
# records top_score, result_count and the query for every call, so the
|
||||||
|
# evidence now arrives on its own:
|
||||||
|
#
|
||||||
|
# - rules clear the bar often and at high scores -> the fear was justified,
|
||||||
|
# the filter was a crude proxy for a bar set too low, and the WORK IS THE
|
||||||
|
# BAR. Any reinstated filter should then carry a measured reason.
|
||||||
|
# - rules clear rarely, in a thin band near the bar -> the filter was never
|
||||||
|
# the right instrument and relevance was always sufficient.
|
||||||
|
#
|
||||||
|
# Only the eligibility moved. The bar and k=1 were both left exactly where
|
||||||
|
# they were, so the resulting distribution has one cause.
|
||||||
|
|
||||||
|
# How much of a command reaches the embedding (#3476). A shell call is not a
|
||||||
|
# file: most are short, and the ones that are not are usually a heredoc or a
|
||||||
|
# pasted script whose bulk says nothing about which rule applies. The VERB AND
|
||||||
|
# ITS TARGET sit at the front — `curl https://git.fabledsword.com/api/...`,
|
||||||
|
# `docker compose up`, `git checkout -b` — and that head is the whole signal.
|
||||||
|
# Sending the tail as well would push it out of a 512-token window and let a
|
||||||
|
# heredoc's prose decide the match.
|
||||||
|
_TOOL_QUERY_CHARS = 400
|
||||||
|
|
||||||
# Minimum SUBSTANCE (non-whitespace chars) a payload must carry before the
|
# Minimum SUBSTANCE (non-whitespace chars) a payload must carry before the
|
||||||
# semantic arm will run at all — the cheap half of the operator's #89 idea
|
# semantic arm will run at all — the cheap half of the operator's #89 idea
|
||||||
# ("a sliding scale between number of characters and semantic threshold").
|
# ("a sliding scale between number of characters and semantic threshold").
|
||||||
@@ -351,6 +463,7 @@ async def _reserve_slot_for_reuse(
|
|||||||
|
|
||||||
top_k = cfg["top_k"]
|
top_k = cfg["top_k"]
|
||||||
_t0 = time.perf_counter()
|
_t0 = time.perf_counter()
|
||||||
|
_rep: dict = {}
|
||||||
reuse = await semantic_search_notes(
|
reuse = await semantic_search_notes(
|
||||||
user_id, query,
|
user_id, query,
|
||||||
limit=1,
|
limit=1,
|
||||||
@@ -359,6 +472,7 @@ async def _reserve_slot_for_reuse(
|
|||||||
exclude_ids=exclude_ids | {int(n.id) for _s, n in kept},
|
exclude_ids=exclude_ids | {int(n.id) for _s, n in kept},
|
||||||
note_type=_REUSE_KINDS,
|
note_type=_REUSE_KINDS,
|
||||||
scope="browse",
|
scope="browse",
|
||||||
|
report=_rep,
|
||||||
)
|
)
|
||||||
# A real semantic query competing for a menu slot — logged like the scored
|
# A real semantic query competing for a menu slot — logged like the scored
|
||||||
# arm it displaces. Before this, the hit it PUSHED OUT was in
|
# arm it displaces. Before this, the hit it PUSHED OUT was in
|
||||||
@@ -369,6 +483,7 @@ async def _reserve_slot_for_reuse(
|
|||||||
user_id=user_id, source="reuse_slot", query=query,
|
user_id=user_id, source="reuse_slot", query=query,
|
||||||
threshold=cfg["threshold"], limit=1, project_id=project_id,
|
threshold=cfg["threshold"], limit=1, project_id=project_id,
|
||||||
is_task=None, results=reuse,
|
is_task=None, results=reuse,
|
||||||
|
best_available=_rep.get("best_available_score"),
|
||||||
duration_ms=(time.perf_counter() - _t0) * 1000.0,
|
duration_ms=(time.perf_counter() - _t0) * 1000.0,
|
||||||
)
|
)
|
||||||
# Verify the kind rather than trusting the query that asked for it, and
|
# Verify the kind rather than trusting the query that asked for it, and
|
||||||
@@ -418,6 +533,7 @@ async def build_autoinject_hint(
|
|||||||
return empty
|
return empty
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
|
_rep_ai: dict = {}
|
||||||
hits = await semantic_search_notes(
|
hits = await semantic_search_notes(
|
||||||
user_id, q,
|
user_id, q,
|
||||||
limit=cfg["top_k"],
|
limit=cfg["top_k"],
|
||||||
@@ -429,11 +545,13 @@ async def build_autoinject_hint(
|
|||||||
# still appear is a collaborator's note inside a shared project — legible
|
# still appear is a collaborator's note inside a shared project — legible
|
||||||
# only because the line below names its owner.
|
# only because the line below names its owner.
|
||||||
scope="browse",
|
scope="browse",
|
||||||
|
report=_rep_ai,
|
||||||
)
|
)
|
||||||
record_retrieval(
|
record_retrieval(
|
||||||
user_id=user_id, source="auto_inject", query=q,
|
user_id=user_id, source="auto_inject", query=q,
|
||||||
threshold=cfg["threshold"], limit=cfg["top_k"],
|
threshold=cfg["threshold"], limit=cfg["top_k"],
|
||||||
project_id=(project_id or None), is_task=None, results=hits,
|
project_id=(project_id or None), is_task=None, results=hits,
|
||||||
|
best_available=_rep_ai.get("best_available_score"),
|
||||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||||
)
|
)
|
||||||
if not hits:
|
if not hits:
|
||||||
@@ -690,10 +808,19 @@ async def get_writepath_config(user_id: int) -> dict:
|
|||||||
threshold = WRITEPATH_DEFAULT_THRESHOLD
|
threshold = WRITEPATH_DEFAULT_THRESHOLD
|
||||||
threshold = min(1.0, max(0.0, threshold))
|
threshold = min(1.0, max(0.0, threshold))
|
||||||
|
|
||||||
|
try:
|
||||||
|
rule_threshold = float(await get_setting(
|
||||||
|
user_id, RULEHINT_THRESHOLD_KEY, str(RULEHINT_DEFAULT_THRESHOLD)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
rule_threshold = RULEHINT_DEFAULT_THRESHOLD
|
||||||
|
rule_threshold = min(1.0, max(0.0, rule_threshold))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
**cfg,
|
**cfg,
|
||||||
"enabled": enabled_raw.strip().lower() in ("true", "1", "yes", "on"),
|
"enabled": enabled_raw.strip().lower() in ("true", "1", "yes", "on"),
|
||||||
"threshold": threshold,
|
"threshold": threshold,
|
||||||
|
# Its own bar, for a third corpus — see RULEHINT_DEFAULT_THRESHOLD.
|
||||||
|
"rule_threshold": rule_threshold,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -708,6 +835,7 @@ async def build_write_path_hint(
|
|||||||
repo_key: str = "",
|
repo_key: str = "",
|
||||||
exclude_derive: list[str] | None = None,
|
exclude_derive: list[str] | None = None,
|
||||||
exclude_rule_ids: list[int] | None = None,
|
exclude_rule_ids: list[int] | None = None,
|
||||||
|
rules_etag: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
||||||
|
|
||||||
@@ -850,6 +978,7 @@ async def build_write_path_hint(
|
|||||||
# Pulled-and-seen ids stay in the query (as evidence) but never in
|
# Pulled-and-seen ids stay in the query (as evidence) but never in
|
||||||
# the menu — the dedup contract holds, the resemblance still lands.
|
# the menu — the dedup contract holds, the resemblance still lands.
|
||||||
pulled_seen = seen & set(pulled)
|
pulled_seen = seen & set(pulled)
|
||||||
|
_rep_wp: dict = {}
|
||||||
hits = await semantic_search_notes(
|
hits = await semantic_search_notes(
|
||||||
user_id, query,
|
user_id, query,
|
||||||
limit=remaining + len(pulled_seen),
|
limit=remaining + len(pulled_seen),
|
||||||
@@ -873,12 +1002,40 @@ async def build_write_path_hint(
|
|||||||
# Same reasoning as auto-inject: nobody asked for this, so it takes
|
# Same reasoning as auto-inject: nobody asked for this, so it takes
|
||||||
# the browse scope and never surfaces a one-to-one direct share.
|
# the browse scope and never surfaces a one-to-one direct share.
|
||||||
scope="browse",
|
scope="browse",
|
||||||
|
report=_rep_wp,
|
||||||
)
|
)
|
||||||
resembles = {
|
resembles = {
|
||||||
int(note.id): float(score) for score, note in hits
|
int(note.id): float(score) for score, note in hits
|
||||||
if int(note.id) in pulled
|
if int(note.id) in pulled
|
||||||
}
|
}
|
||||||
hits = [(s, n) for s, n in hits if int(n.id) not in seen][:remaining]
|
shown = [(s, n) for s, n in hits if int(n.id) not in seen]
|
||||||
|
# WHAT THIS ARM WITHHELD AFTER THE SEARCH ANSWERED, and the reason
|
||||||
|
# `best_available_score` cannot always be reported here (#3739 again,
|
||||||
|
# from the side its fix did not reach).
|
||||||
|
#
|
||||||
|
# This arm is the one note arm that filters TWICE. `exclude_ids` takes
|
||||||
|
# `seen - pulled_seen` into the search, but the pulled-and-seen ids stay
|
||||||
|
# in the query deliberately — `resembles` above needs them — and are
|
||||||
|
# dropped in the line above instead. So the score the search reported is
|
||||||
|
# PRE that drop while the row's `result_count` is POST it, and a record
|
||||||
|
# the session had already been shown could be logged as something the
|
||||||
|
# BAR turned away. Live proof on the first read after #3739 shipped:
|
||||||
|
# write_path's near-miss max was 0.822 while the lowest score it ever
|
||||||
|
# RETURNED was 0.6857 — a "rejection" that beat every acceptance.
|
||||||
|
#
|
||||||
|
# The suppression column cannot rescue it the way it does for the rule
|
||||||
|
# arms: this arm's count would be PARTIAL, covering only the drops made
|
||||||
|
# here and not the ones `exclude_ids` made inside the search, and a
|
||||||
|
# partial number under a name that reads as complete is the substitution
|
||||||
|
# this whole milestone exists to stop.
|
||||||
|
#
|
||||||
|
# So the honest answer is null — "not measured on this call" — whenever
|
||||||
|
# this filter removed anything, because then the bar is not the only
|
||||||
|
# thing that turned something away and the reported score may belong to
|
||||||
|
# a record we withheld ourselves. Calls where nothing was dropped keep
|
||||||
|
# reporting it, which is most of them.
|
||||||
|
withheld_here = len(hits) - len(shown)
|
||||||
|
hits = shown[:remaining]
|
||||||
record_retrieval(
|
record_retrieval(
|
||||||
user_id=user_id, source="write_path", query=query,
|
user_id=user_id, source="write_path", query=query,
|
||||||
threshold=cfg["threshold"], limit=remaining,
|
threshold=cfg["threshold"], limit=remaining,
|
||||||
@@ -886,6 +1043,9 @@ async def build_write_path_hint(
|
|||||||
# recording it as a notes-only retrieval would misdescribe the
|
# recording it as a notes-only retrieval would misdescribe the
|
||||||
# candidate set the threshold is being tuned against.
|
# candidate set the threshold is being tuned against.
|
||||||
project_id=scope_project, is_task=None, results=hits,
|
project_id=scope_project, is_task=None, results=hits,
|
||||||
|
best_available=(
|
||||||
|
None if withheld_here else _rep_wp.get("best_available_score")
|
||||||
|
),
|
||||||
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
duration_ms=(time.perf_counter() - t0) * 1000.0,
|
||||||
)
|
)
|
||||||
if hits:
|
if hits:
|
||||||
@@ -951,7 +1111,74 @@ async def build_write_path_hint(
|
|||||||
derive = [d for d in found if d.get("key") not in skip]
|
derive = [d for d in found if d.get("key") not in skip]
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("write-time derive check failed", exc_info=True)
|
logger.warning("write-time derive check failed", exc_info=True)
|
||||||
if not synced and not menu and not stamped and not divergence and not derive:
|
staleness: list[str] = []
|
||||||
|
# ── Have the rules moved under this session? (milestone 323) ───────
|
||||||
|
#
|
||||||
|
# THE CARRIER IS THE POINT. This hook already fires before a write — the
|
||||||
|
# moment acting on a stale rule actually costs something — and the check
|
||||||
|
# is one comparison against a marker the session already holds. No
|
||||||
|
# payload, no extra round trip, and nothing said when nothing moved.
|
||||||
|
#
|
||||||
|
# WHAT THIS CANNOT SEE, and a reader who finds an etag here will assume
|
||||||
|
# otherwise:
|
||||||
|
#
|
||||||
|
# what goes wrong | caught?
|
||||||
|
# ---------------------------------------------------|--------
|
||||||
|
# another session edits a rule mid-flight | yes
|
||||||
|
# the session is misremembering a rule read hours ago | yes
|
||||||
|
# compaction summarised the rules out of context | NO
|
||||||
|
#
|
||||||
|
# The third is the most common and this is blind to it: the etag was in
|
||||||
|
# context too and went with the rules. The SessionStart nudge is that
|
||||||
|
# case's only mechanism and must not be softened because this shipped.
|
||||||
|
#
|
||||||
|
# Fails open, like every other arm here: a staleness hint must never
|
||||||
|
# break a write.
|
||||||
|
if rules_etag:
|
||||||
|
try:
|
||||||
|
current = await rulebooks_svc.list_always_on_rules(
|
||||||
|
user_id, project_id=project_id or 0,
|
||||||
|
)
|
||||||
|
if rulebooks_svc.rules_etag(current) != rules_etag:
|
||||||
|
moved = rulebooks_svc.rules_moved_since(current, rules_etag)
|
||||||
|
held = rulebooks_svc.etag_count(rules_etag)
|
||||||
|
bits = []
|
||||||
|
if moved:
|
||||||
|
named = ", ".join(
|
||||||
|
f"#{r.id} \u201c{r.title}\u201d" for r in moved[:3]
|
||||||
|
)
|
||||||
|
more = len(moved) - 3
|
||||||
|
bits.append(
|
||||||
|
f"{named}" + (f", and {more} more" if more > 0 else "")
|
||||||
|
)
|
||||||
|
# A DELETED rule moves no timestamp and leaves no row to name,
|
||||||
|
# so the count is the only thing that can report the one change
|
||||||
|
# that takes an instruction OUT of force.
|
||||||
|
if held is not None and held != len(current):
|
||||||
|
delta = len(current) - held
|
||||||
|
bits.append(
|
||||||
|
f"{abs(delta)} rule(s) {'added' if delta > 0 else 'no longer in force'}"
|
||||||
|
)
|
||||||
|
if bits:
|
||||||
|
staleness.append(
|
||||||
|
"Your loaded rules have changed since this session "
|
||||||
|
"started — " + "; ".join(bits) + ". Re-read them with "
|
||||||
|
"list_always_on_rules() before relying on the set you "
|
||||||
|
"are holding."
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("write-path rules-etag arm failed", exc_info=True)
|
||||||
|
|
||||||
|
# The guard sits BELOW the staleness arm on purpose. A rules change is
|
||||||
|
# unconditional news — it does not become less true because this
|
||||||
|
# particular write happened to match no prior art — and this arm is one
|
||||||
|
# indexed query, only when the session actually sent a marker.
|
||||||
|
#
|
||||||
|
# The standing-rule arm further down is deliberately left on the far side
|
||||||
|
# of this guard: that one runs a SEMANTIC search, and moving it here would
|
||||||
|
# run an embedding query on every write in the session. Its gating is a
|
||||||
|
# separate question from this one (see the note on #3244).
|
||||||
|
if not staleness and not synced and not menu and not stamped and not divergence and not derive:
|
||||||
return empty
|
return empty
|
||||||
|
|
||||||
owners = await owner_names_for({
|
owners = await owner_names_for({
|
||||||
@@ -970,7 +1197,9 @@ async def build_write_path_hint(
|
|||||||
for marker, item in menu:
|
for marker, item in menu:
|
||||||
rendered.append((item, marker, _owner_of(item), _foreign_language(item, target_lang)))
|
rendered.append((item, marker, _owner_of(item), _foreign_language(item, target_lang)))
|
||||||
|
|
||||||
lines: list[str] = []
|
# Seeded with the staleness line, which is decided above the early
|
||||||
|
# return and so cannot wait for this list to exist.
|
||||||
|
lines: list[str] = list(staleness)
|
||||||
sync_note_ids: list[int] = []
|
sync_note_ids: list[int] = []
|
||||||
if synced:
|
if synced:
|
||||||
# The sync framing (#2708). Deliberately imperative about the record —
|
# The sync framing (#2708). Deliberately imperative about the record —
|
||||||
@@ -1055,10 +1284,18 @@ async def build_write_path_hint(
|
|||||||
rule_ids: list[int] = []
|
rule_ids: list[int] = []
|
||||||
try:
|
try:
|
||||||
already = set(exclude_rule_ids or [])
|
already = set(exclude_rule_ids or [])
|
||||||
|
# Timed like the notes arm above. Without this the rule row was the one
|
||||||
|
# source in the whole readout reporting a null p90_duration_ms (#3311)
|
||||||
|
# — a gap that reads as "this surface is somehow not measurable" rather
|
||||||
|
# than "nobody passed the number".
|
||||||
|
rule_t0 = time.perf_counter()
|
||||||
|
_rep_wpr: dict = {}
|
||||||
hits = await semantic_search_rules(
|
hits = await semantic_search_rules(
|
||||||
user_id, code or path, limit=2,
|
user_id, code or path, limit=RULEHINT_LIMIT,
|
||||||
threshold=cfg["threshold"], tier="conditional",
|
threshold=cfg["rule_threshold"],
|
||||||
|
report=_rep_wpr,
|
||||||
)
|
)
|
||||||
|
rule_ms = (time.perf_counter() - rule_t0) * 1000.0
|
||||||
fresh = [(score, rule) for score, rule in hits if rule.id not in already]
|
fresh = [(score, rule) for score, rule in hits if rule.id not in already]
|
||||||
for _score, rule in fresh:
|
for _score, rule in fresh:
|
||||||
trigger = (rule.when_to_apply or "").strip()
|
trigger = (rule.when_to_apply or "").strip()
|
||||||
@@ -1069,15 +1306,55 @@ async def build_write_path_hint(
|
|||||||
"does not apply; it is not in this session's loaded set."
|
"does not apply; it is not in this session's loaded set."
|
||||||
)
|
)
|
||||||
rule_ids.append(rule.id)
|
rule_ids.append(rule.id)
|
||||||
|
# TWO tables, and the split is not arbitrary. retrieval_logs is one
|
||||||
|
# row per CALL, keyed on the score distribution a threshold is tuned
|
||||||
|
# from. rule_usage_events is one row per RULE per event, which is the
|
||||||
|
# grain "was this hint ever acted on" needs and the grain a JSONB
|
||||||
|
# result_ids array cannot be indexed at.
|
||||||
|
#
|
||||||
|
# This comment used to say rule ids had nowhere to go — that
|
||||||
|
# note_usage_events remaps ids on restore, so a rule id there would
|
||||||
|
# return attached to whatever note took that number. That is still
|
||||||
|
# true of the NOTE table, and it is exactly why rule_usage_events is
|
||||||
|
# its own (milestone 333 step 1). The gap it described is closed.
|
||||||
|
#
|
||||||
|
# THE CALL LOG IS UNCONDITIONAL; THE SURFACING LOG IS NOT, and the
|
||||||
|
# asymmetry is the correction #3497 exists to make. Both used to sit
|
||||||
|
# inside an `if fresh:`, which is how this arm came to report
|
||||||
|
# `zero_result_calls: 0` and `cleared_threshold: 133/133` — not a
|
||||||
|
# perfectly tuned surface but one structurally unable to record its
|
||||||
|
# own misses. #3311 read that artifact as a measurement and a whole
|
||||||
|
# milestone was scoped on it. A call that found nothing is the ONLY
|
||||||
|
# evidence a threshold is set too high, and it is the row every note
|
||||||
|
# surface has always written (write_path: 421 zeroes of 613 calls;
|
||||||
|
# auto_inject: 114 of 326). A SURFACING is different in kind: nothing
|
||||||
|
# was shown, so no such event occurred, and its log stays guarded.
|
||||||
|
#
|
||||||
|
# `results=fresh`, not `hits`: the note arms pass their exclusions
|
||||||
|
# INTO semantic_search_notes, so what they log is already
|
||||||
|
# post-exclusion. semantic_search_rules takes no such parameter and
|
||||||
|
# this filter is where the equivalent happens — logging `hits` would
|
||||||
|
# quietly make this row mean something other than every other row in
|
||||||
|
# the same readout.
|
||||||
|
record_retrieval(
|
||||||
|
user_id=user_id, source="write_path_rule", query=code or path,
|
||||||
|
threshold=cfg["rule_threshold"], limit=RULEHINT_LIMIT,
|
||||||
|
project_id=project_id,
|
||||||
|
is_task=None, results=fresh, duration_ms=rule_ms,
|
||||||
|
best_available=_rep_wpr.get("best_available_score"),
|
||||||
|
# What the ranker found and this session had already been told.
|
||||||
|
# Without it a zero row cannot say whether the bar was too high or
|
||||||
|
# the reader was simply ahead of it — and only the first is a
|
||||||
|
# reason to move the threshold.
|
||||||
|
suppressed=len(hits) - len(fresh),
|
||||||
|
)
|
||||||
if fresh:
|
if fresh:
|
||||||
# retrieval_logs, NOT note_usage_events: that table's ids are
|
# `rule_ids` is `fresh`, i.e. AFTER exclude_rule_ids. A rule the
|
||||||
# remapped on a backup restore, so a rule id there would return
|
# session already holds was considered and not shown, and counting
|
||||||
# attached to whatever note took that number. This one is never
|
# it would inflate the denominator with claims the agent never saw
|
||||||
# restored, and `source` already separates the surfaces.
|
# — which reads as a precision problem this arm does not have.
|
||||||
record_retrieval(
|
record_rule_surfaced(
|
||||||
user_id=user_id, source="write_path_rule", query=code or path,
|
user_id=user_id, rule_ids=rule_ids, source="write_path_rule",
|
||||||
threshold=cfg["threshold"], limit=2, project_id=project_id,
|
|
||||||
is_task=None, results=fresh,
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("write-path rule arm failed", exc_info=True)
|
logger.debug("write-path rule arm failed", exc_info=True)
|
||||||
@@ -1095,6 +1372,117 @@ async def build_write_path_hint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def build_tool_rule_hint(
|
||||||
|
user_id: int,
|
||||||
|
tool_name: str,
|
||||||
|
command: str,
|
||||||
|
*,
|
||||||
|
project_id: int = 0,
|
||||||
|
exclude_rule_ids: list[int] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Standing rules that may apply to the ACTION about to be taken (#3476).
|
||||||
|
|
||||||
|
The sibling of the write-path rule arm, and the surface that was missing.
|
||||||
|
That arm is keyed on `code or path`, so a rule can only be retrieved at the
|
||||||
|
moment of a code WRITE. Every rule about which tool to reach for — don't
|
||||||
|
curl the forge, don't stand up a stack, don't run the suite locally, don't
|
||||||
|
branch — was therefore unreachable at the moment it mattered, and residency
|
||||||
|
in the always-on preload was the only surface it had.
|
||||||
|
|
||||||
|
WHY A MECHANICAL TRIGGER AND NOT AN INSTRUCTION. Note #3089's finding is
|
||||||
|
that a reflex generates no query: you reach for `curl` confidently, with no
|
||||||
|
moment of doubt, so any surface that waits to be asked never fires. Here
|
||||||
|
nothing has to be asked — the tool call IS the query, and the reflex has to
|
||||||
|
become a tool call before it can do anything.
|
||||||
|
|
||||||
|
Deliberately TOOL-AGNOSTIC: takes a name and a string. The hook decides
|
||||||
|
which tools it watches, so widening the matcher is a `hooks.json` edit with
|
||||||
|
no change here.
|
||||||
|
|
||||||
|
CONDITIONAL ONLY, exactly as the write-path arm — an always-on rule is
|
||||||
|
already resident and repeating it is noise. That filter is also the
|
||||||
|
transition this arm exists to enable: re-tier a rule to `conditional` and
|
||||||
|
it starts arriving here instead of in every session's preamble.
|
||||||
|
|
||||||
|
Fails open and returns an empty context on any error: a recall aid may
|
||||||
|
never break the operator's action.
|
||||||
|
"""
|
||||||
|
out: dict = {"context": "", "rule_ids": []}
|
||||||
|
command = (command or "").strip()
|
||||||
|
if not command:
|
||||||
|
return out
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg = await get_writepath_config(user_id)
|
||||||
|
if not cfg.get("enabled"):
|
||||||
|
return out
|
||||||
|
|
||||||
|
# The command text is the query. A long heredoc or a pasted script
|
||||||
|
# would otherwise push the meaningful head of the command out of the
|
||||||
|
# embedding window, so it is bounded — the verb and its target sit at
|
||||||
|
# the front, which is the part a rule is about.
|
||||||
|
query = command[:_TOOL_QUERY_CHARS]
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
_rep_ptr: dict = {}
|
||||||
|
hits = await semantic_search_rules(
|
||||||
|
user_id, query, limit=RULEHINT_LIMIT,
|
||||||
|
threshold=cfg["rule_threshold"],
|
||||||
|
report=_rep_ptr,
|
||||||
|
)
|
||||||
|
duration_ms = (time.perf_counter() - t0) * 1000.0
|
||||||
|
|
||||||
|
already = set(exclude_rule_ids or [])
|
||||||
|
fresh = [(score, rule) for score, rule in hits if rule.id not in already]
|
||||||
|
|
||||||
|
# Logged BEFORE the early return, for the reason spelled out at length
|
||||||
|
# on the write-path arm above: a call that found nothing is the only
|
||||||
|
# evidence a threshold is too high, and an arm that logs only the calls
|
||||||
|
# it liked reports a flawless clear-rate however badly it is tuned.
|
||||||
|
# This arm shipped with the same defect inherited from its sibling, and
|
||||||
|
# it mattered more here — a surface with no rows at all cannot be told
|
||||||
|
# apart from a hook that never fired, which is precisely the silent
|
||||||
|
# failure the arm was built to stop.
|
||||||
|
record_retrieval(
|
||||||
|
user_id=user_id, source="pre_tool_rule", query=query,
|
||||||
|
threshold=cfg["rule_threshold"], limit=RULEHINT_LIMIT,
|
||||||
|
project_id=project_id,
|
||||||
|
is_task=None, results=fresh, duration_ms=duration_ms,
|
||||||
|
best_available=_rep_ptr.get("best_available_score"),
|
||||||
|
# See the sibling arm. It matters more here: this arm fires on every
|
||||||
|
# Bash call, so a long session excludes its way to an all-zero row
|
||||||
|
# and the threshold looks wrong when nothing about it is.
|
||||||
|
suppressed=len(hits) - len(fresh),
|
||||||
|
)
|
||||||
|
if not fresh:
|
||||||
|
return out
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
rule_ids: list[int] = []
|
||||||
|
for _score, rule in fresh:
|
||||||
|
trigger = (rule.when_to_apply or "").strip()
|
||||||
|
lines.append(
|
||||||
|
f"Standing rule that may apply to this {tool_name} call — "
|
||||||
|
f"“{rule.title}”"
|
||||||
|
+ (f" ({trigger})" if trigger else "")
|
||||||
|
+ f". Read it with get_rule({rule.id}) before deciding it "
|
||||||
|
"does not apply; it is not in this session's loaded set."
|
||||||
|
)
|
||||||
|
rule_ids.append(rule.id)
|
||||||
|
|
||||||
|
# RANKED, not ambient: this arm chose what it showed, so a pull can
|
||||||
|
# settle whether the choice was any good. `rule_usage.RANKED_SOURCES`
|
||||||
|
# carries the same name.
|
||||||
|
record_rule_surfaced(
|
||||||
|
user_id=user_id, rule_ids=rule_ids, source="pre_tool_rule",
|
||||||
|
)
|
||||||
|
out["context"] = "\n".join(lines)
|
||||||
|
out["rule_ids"] = rule_ids
|
||||||
|
except Exception:
|
||||||
|
logger.debug("pre-tool rule arm failed", exc_info=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _derive_line(path: str, derive: list[dict]) -> str:
|
def _derive_line(path: str, derive: list[dict]) -> str:
|
||||||
"""The ledger's word on the names being written (#2900): a duplicate
|
"""The ledger's word on the names being written (#2900): a duplicate
|
||||||
family to derive, or a canon to reuse — said at the write."""
|
family to derive, or a canon to reuse — said at the write."""
|
||||||
@@ -1208,7 +1596,10 @@ async def build_session_context(
|
|||||||
its normalized key — triggers a one-line "bind this repo" hint so
|
its normalized key — triggers a one-line "bind this repo" hint so
|
||||||
the binding is self-healing.
|
the binding is self-healing.
|
||||||
|
|
||||||
Returns {"context": str, "rule_count": int, "project": dict | None}.
|
Returns {"context": str, "rule_count": int, "project": dict | None,
|
||||||
|
"rules_etag": str}. The etag is for the HOOK, not for the model — the
|
||||||
|
hook stores it and hands it back on each write so the server can say
|
||||||
|
whether these rules have moved since the session loaded them.
|
||||||
`context` is markdown ready to drop into `additionalContext`; it is capped
|
`context` is markdown ready to drop into `additionalContext`; it is capped
|
||||||
at _MAX_CHARS with an explicit truncation note so the hook can pass it
|
at _MAX_CHARS with an explicit truncation note so the hook can pass it
|
||||||
through verbatim.
|
through verbatim.
|
||||||
@@ -1217,6 +1608,24 @@ async def build_session_context(
|
|||||||
# exclusion (milestone 297) takes a rulebook out of this block, and is
|
# exclusion (milestone 297) takes a rulebook out of this block, and is
|
||||||
# named below so the departure is visible rather than silent.
|
# named below so the departure is visible rather than silent.
|
||||||
rules = await rulebooks_svc.list_always_on_rules(user_id, project_id=project_id)
|
rules = await rulebooks_svc.list_always_on_rules(user_id, project_id=project_id)
|
||||||
|
# AMBIENT source, and the one that matters most: this is the preload — the
|
||||||
|
# block every session opens with, chosen by nobody, paid for every turn.
|
||||||
|
#
|
||||||
|
# It emitted nothing until 2026-09-03, which made the resident set's cost
|
||||||
|
# certain and its usefulness unfalsifiable at the same time (#3473). Note
|
||||||
|
# #3089 is the argument this measurement finally lets someone test: that a
|
||||||
|
# rule arriving with thirty others, none of them relevant, is read as
|
||||||
|
# preamble rather than as a claim — so presence is not surfacing, and a
|
||||||
|
# tier-1 set can grow without anybody noticing it stopped working.
|
||||||
|
#
|
||||||
|
# Recorded even when the hook truncates the block below: the rules WERE
|
||||||
|
# delivered, and counting only the untruncated ones would quietly shrink
|
||||||
|
# the denominator exactly where the set is too big to read.
|
||||||
|
record_rule_surfaced(
|
||||||
|
user_id=user_id,
|
||||||
|
rule_ids=[r.id for r in rules],
|
||||||
|
source="session_start",
|
||||||
|
)
|
||||||
excluded = (
|
excluded = (
|
||||||
await rulebooks_svc.excluded_always_on_rulebooks(user_id, project_id)
|
await rulebooks_svc.excluded_always_on_rulebooks(user_id, project_id)
|
||||||
if project_id else []
|
if project_id else []
|
||||||
@@ -1317,4 +1726,12 @@ async def build_session_context(
|
|||||||
if len(context) > _MAX_CHARS:
|
if len(context) > _MAX_CHARS:
|
||||||
context = context[:_MAX_CHARS].rstrip() + "\n\n…(truncated — call list_always_on_rules())"
|
context = context[:_MAX_CHARS].rstrip() + "\n\n…(truncated — call list_always_on_rules())"
|
||||||
|
|
||||||
return {"context": context, "rule_count": len(rules), "project": project_dict}
|
return {
|
||||||
|
"context": context,
|
||||||
|
"rule_count": len(rules),
|
||||||
|
"project": project_dict,
|
||||||
|
# Computed from the rules THIS payload was built from, not re-queried:
|
||||||
|
# the marker has to describe the set the session is actually holding,
|
||||||
|
# and a second query could disagree with the first.
|
||||||
|
"rules_etag": rulebooks_svc.rules_etag(rules),
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ from scribe.models import async_session
|
|||||||
from scribe.models.base import iso
|
from scribe.models.base import iso
|
||||||
from scribe.models.note import Note
|
from scribe.models.note import Note
|
||||||
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
|
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
|
||||||
|
from scribe.models.rule_usage import PULLED as RULE_PULLED
|
||||||
|
from scribe.models.rule_usage import SURFACED as RULE_SURFACED
|
||||||
|
from scribe.models.rule_usage import RuleUsageEvent
|
||||||
|
from scribe.services.rule_usage import is_ambient
|
||||||
from scribe.models.retrieval_log import RetrievalLog
|
from scribe.models.retrieval_log import RetrievalLog
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -51,12 +55,26 @@ def _build_payload(
|
|||||||
is_task: bool | None,
|
is_task: bool | None,
|
||||||
results: list[tuple[float, Note]],
|
results: list[tuple[float, Note]],
|
||||||
duration_ms: float | None,
|
duration_ms: float | None,
|
||||||
|
suppressed: int | None = None,
|
||||||
|
best_available: float | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Reduce a retrieval call to a flat, JSON-safe RetrievalLog payload.
|
"""Reduce a retrieval call to a flat, JSON-safe RetrievalLog payload.
|
||||||
|
|
||||||
Pure and synchronous (no DB, no event loop) so it is unit-testable and safe
|
Pure and synchronous (no DB, no event loop) so it is unit-testable and safe
|
||||||
to run inline before scheduling the write. `results` is the
|
to run inline before scheduling the write. `results` is the
|
||||||
`(score, Note)` list from semantic_search_notes, already highest-first.
|
`(score, Note)` list from semantic_search_notes, already highest-first.
|
||||||
|
|
||||||
|
`suppressed` is how many scored hits the caller dropped because the session
|
||||||
|
had already been shown them, and it stays None for callers that cannot
|
||||||
|
know. See the column's comment: None means "not measured here", which is a
|
||||||
|
different fact from 0 and must never render as one.
|
||||||
|
|
||||||
|
`best_available` is the highest score the ranker reached BEFORE the
|
||||||
|
threshold, and it carries the same null discipline for a sharper reason: it
|
||||||
|
is the only field that still says something on a call that returned
|
||||||
|
nothing, so a 0.0 standing in for "not measured" would read as "the corpus
|
||||||
|
held nothing remotely relevant" — a claim about the corpus invented out of
|
||||||
|
a caller's silence.
|
||||||
"""
|
"""
|
||||||
items = [
|
items = [
|
||||||
{"id": int(note.id), "score": round(float(score), 5), "rank": rank}
|
{"id": int(note.id), "score": round(float(score), 5), "rank": rank}
|
||||||
@@ -72,8 +90,12 @@ def _build_payload(
|
|||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"is_task": is_task,
|
"is_task": is_task,
|
||||||
"result_count": len(items),
|
"result_count": len(items),
|
||||||
|
"suppressed_count": (None if suppressed is None else int(suppressed)),
|
||||||
"top_score": (scores[0] if scores else None),
|
"top_score": (scores[0] if scores else None),
|
||||||
"min_score": (scores[-1] if scores else None),
|
"min_score": (scores[-1] if scores else None),
|
||||||
|
"best_available_score": (
|
||||||
|
None if best_available is None else round(float(best_available), 5)
|
||||||
|
),
|
||||||
"result_ids": items,
|
"result_ids": items,
|
||||||
"duration_ms": (round(duration_ms, 2) if duration_ms is not None else None),
|
"duration_ms": (round(duration_ms, 2) if duration_ms is not None else None),
|
||||||
}
|
}
|
||||||
@@ -111,6 +133,8 @@ def record_retrieval(
|
|||||||
is_task: bool | None,
|
is_task: bool | None,
|
||||||
results: list[tuple[float, Any]],
|
results: list[tuple[float, Any]],
|
||||||
duration_ms: float | None = None,
|
duration_ms: float | None = None,
|
||||||
|
suppressed: int | None = None,
|
||||||
|
best_available: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Fire-and-forget: record one retrieval call.
|
"""Fire-and-forget: record one retrieval call.
|
||||||
|
|
||||||
@@ -136,6 +160,8 @@ def record_retrieval(
|
|||||||
is_task=is_task,
|
is_task=is_task,
|
||||||
results=results,
|
results=results,
|
||||||
duration_ms=duration_ms,
|
duration_ms=duration_ms,
|
||||||
|
suppressed=suppressed,
|
||||||
|
best_available=best_available,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("retrieval telemetry payload build failed", exc_info=True)
|
logger.debug("retrieval telemetry payload build failed", exc_info=True)
|
||||||
@@ -162,36 +188,145 @@ def record_retrieval(
|
|||||||
|
|
||||||
def _bucket(rows: list) -> dict:
|
def _bucket(rows: list) -> dict:
|
||||||
"""A score readout a human can act on, from one aggregate row."""
|
"""A score readout a human can act on, from one aggregate row."""
|
||||||
calls, zero, cleared, p10, p50, p90, lo, hi, avg_n, dur = rows
|
(calls, zero, p10, p50, p90, lo, hi, avg_n, dur,
|
||||||
|
measured, supp_calls, supp_zero,
|
||||||
|
miss_calls, miss_p50, miss_p90, miss_max) = rows
|
||||||
return {
|
return {
|
||||||
"calls": int(calls or 0),
|
"calls": int(calls or 0),
|
||||||
# A call that returned nothing is not a low-scoring call — it is a
|
# A call that returned nothing is not a low-scoring call — it is a
|
||||||
# different failure (nothing indexed, filter too narrow), and averaging
|
# different failure (nothing indexed, filter too narrow), and averaging
|
||||||
# it into the score distribution would hide both.
|
# it into the score distribution would hide both.
|
||||||
"zero_result_calls": int(zero or 0),
|
"zero_result_calls": int(zero or 0),
|
||||||
# How often the best hit actually cleared the threshold in force for
|
# `cleared_threshold` USED TO LIVE HERE and it was a tautology (#3670).
|
||||||
# that call. THE precision-adjacent number: a surface that clears its
|
# The search applies the bar before returning, so every returned result
|
||||||
# bar on almost every call is either well-tuned or too loose, and the
|
# cleared it by construction and a call with nothing has no score to
|
||||||
# score spread below says which.
|
# compare — the condition was true exactly when `result_count > 0`.
|
||||||
"cleared_threshold": int(cleared or 0),
|
# `zero_result_calls + cleared_threshold == calls` held on all nineteen
|
||||||
|
# readings ever taken. It was `calls - zero_result_calls` wearing a name
|
||||||
|
# that promised a second opinion, and the docstring built a reading
|
||||||
|
# procedure on it that asked the reader to compare a number with itself.
|
||||||
|
# Its replacement is `near_misses` below, which the bar cannot fix by
|
||||||
|
# construction because it is measured on the calls the bar REJECTED.
|
||||||
|
# Of the zeros above, which were the RANKER declining and which were
|
||||||
|
# the reader having seen it already? `zero_result_calls` cannot say,
|
||||||
|
# and only the first kind is evidence about the threshold.
|
||||||
|
#
|
||||||
|
# None — not a zeroed dict — when no row in the window reported it. A
|
||||||
|
# surface that filters inside the search genuinely does not know, and
|
||||||
|
# rendering that as `{"calls": 0}` would state a measurement nobody
|
||||||
|
# made. That substitution is the whole of #3311.
|
||||||
|
"suppression": (
|
||||||
|
None if not int(measured or 0) else {
|
||||||
|
"measured_calls": int(measured or 0),
|
||||||
|
"calls_with_suppression": int(supp_calls or 0),
|
||||||
|
# Subtract from zero_result_calls for the true ranker declines.
|
||||||
|
"zero_because_already_shown": int(supp_zero or 0),
|
||||||
|
}
|
||||||
|
),
|
||||||
"top_score": {
|
"top_score": {
|
||||||
"p10": _round(p10), "p50": _round(p50), "p90": _round(p90),
|
"p10": _round(p10), "p50": _round(p50), "p90": _round(p90),
|
||||||
"min": _round(lo), "max": _round(hi),
|
"min": _round(lo), "max": _round(hi),
|
||||||
},
|
},
|
||||||
|
# WHAT THE BAR TURNED AWAY, and the only figure here a threshold can
|
||||||
|
# actually be tuned from. Measured over the calls that returned
|
||||||
|
# NOTHING, on the best score the ranker reached before the filter.
|
||||||
|
#
|
||||||
|
# Read `p90` against the threshold in force. A bar at 0.72 rejecting 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 rejecting 0.30s is doing its
|
||||||
|
# job and the corpus simply had nothing. Both render as a zero-result
|
||||||
|
# call, and nothing else in this readout separates them.
|
||||||
|
#
|
||||||
|
# None — not a zeroed block — when no declining call in the window
|
||||||
|
# measured it. Old rows predate the column, and a 0.0 would assert that
|
||||||
|
# the corpus held nothing relevant, which is a claim about the corpus
|
||||||
|
# invented out of a caller's silence.
|
||||||
|
"near_misses": (
|
||||||
|
None if not int(miss_calls or 0) else {
|
||||||
|
"measured_calls": int(miss_calls or 0),
|
||||||
|
"p50": _round(miss_p50),
|
||||||
|
"p90": _round(miss_p90),
|
||||||
|
"max": _round(miss_max),
|
||||||
|
}
|
||||||
|
),
|
||||||
"avg_result_count": _round(avg_n),
|
"avg_result_count": _round(avg_n),
|
||||||
"p90_duration_ms": _round(dur, 1),
|
"p90_duration_ms": _round(dur, 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# The aggregate row Postgres would have returned for a source with no rows in
|
||||||
|
# the window: nothing counted, nothing scored. Positional, matching the SELECT
|
||||||
|
# `_bucket` unpacks — calls, zero, p10, p50, p90, min, max, avg_n,
|
||||||
|
# dur, measured, supp_calls, supp_zero, miss_calls, miss_p50, miss_p90,
|
||||||
|
# miss_max. The counts are 0 because zero calls is a real observation;
|
||||||
|
# everything else is None because a distribution nobody sampled has no value,
|
||||||
|
# and rendering it as 0.0 would state one.
|
||||||
|
_NO_ROWS_IN_WINDOW = [0, 0, None, None, None, None, None, None, None,
|
||||||
|
0, 0, 0, 0, None, None, None]
|
||||||
|
|
||||||
|
|
||||||
def _round(v, places: int = 4):
|
def _round(v, places: int = 4):
|
||||||
return None if v is None else round(float(v), places)
|
return None if v is None else round(float(v), places)
|
||||||
|
|
||||||
|
|
||||||
|
async def _complete_from(session, model, user_id) -> dict[str, Any]:
|
||||||
|
"""When each source in `model` started being recorded, and the instant the
|
||||||
|
WHOLE table is complete from. Returns {source: earliest_row, "*": latest}.
|
||||||
|
|
||||||
|
THE GRAIN IS THE SOURCE, and that is the whole point. `retrieval_logs` has
|
||||||
|
rows going back months, so a table-level "earliest row" says months and
|
||||||
|
tells a reader their window is fully covered — while a source added last
|
||||||
|
week has a week of rows and a counter that silently means something else.
|
||||||
|
Per-source is the only grain at which partial coverage is visible.
|
||||||
|
|
||||||
|
THE AGGREGATE USES THE LATEST, NOT THE EARLIEST. A number that sums several
|
||||||
|
sources is complete only once EVERY contributor was recording, so "*" is a
|
||||||
|
max over the sources, not a min. Taking the min here would reproduce the
|
||||||
|
exact reading this exists to prevent: the oldest source vouching for the
|
||||||
|
youngest.
|
||||||
|
|
||||||
|
All-time, deliberately unfiltered by the window — a query bounded by
|
||||||
|
`since` can only ever report something at or after `since`, which answers
|
||||||
|
nothing.
|
||||||
|
"""
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(model.source, func.min(model.created_at))
|
||||||
|
.where(model.user_id == user_id)
|
||||||
|
.group_by(model.source)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
out: dict[str, Any] = {src: ts for src, ts in rows if ts is not None}
|
||||||
|
stamps = list(out.values())
|
||||||
|
out["*"] = max(stamps) if stamps else None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _coverage(complete_from, since) -> dict:
|
||||||
|
"""The two keys every counter block carries, from one timestamp.
|
||||||
|
|
||||||
|
`covers_window` is None — never False — when nothing was ever recorded.
|
||||||
|
"No rows at all" is not "partial coverage", it is no measurement, and the
|
||||||
|
null convention #3497 established for `suppression` holds here for the
|
||||||
|
same reason: absent must not read as a verdict.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
# iso() already returns None for an unset value (#2845) — the guard
|
||||||
|
# belongs on covers_window, which is a verdict, not a serialisation.
|
||||||
|
"complete_from": iso(complete_from),
|
||||||
|
"covers_window": (
|
||||||
|
None if complete_from is None else complete_from <= since
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
||||||
"""What the retrieval telemetry says, per surface, over a window.
|
"""What the retrieval telemetry says, per surface, over a window.
|
||||||
|
|
||||||
Two aggregates side by side, each read from the table built for it — NOT a
|
Three aggregates side by side, each read from the table built for it — NOT
|
||||||
join. `NoteUsageEvent`'s own docstring is explicit that the two are
|
a join. `usage` is notes, `rule_usage` is rules, and they stay apart
|
||||||
|
because a few dozen eligible rules blended into thousands of notes is the
|
||||||
|
note ratio with noise on it (milestone 333). `NoteUsageEvent`'s own docstring is explicit that the two are
|
||||||
complements ("RetrievalLog tunes the threshold, this tunes the corpus") and
|
complements ("RetrievalLog tunes the threshold, this tunes the corpus") and
|
||||||
that RetrievalLog's JSONB `result_ids` "can't be indexed at" the per-note
|
that RetrievalLog's JSONB `result_ids` "can't be indexed at" the per-note
|
||||||
grain. So the score distribution comes from `retrieval_logs` on its indexed
|
grain. So the score distribution comes from `retrieval_logs` on its indexed
|
||||||
@@ -199,6 +334,12 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
|||||||
it was built for. Reading each from its own table is both cheaper and more
|
it was built for. Reading each from its own table is both cheaper and more
|
||||||
honest than correlating them through JSONB.
|
honest than correlating them through JSONB.
|
||||||
|
|
||||||
|
`usage["by_source"]` is the one join, and it stays INSIDE
|
||||||
|
`note_usage_events` — surfaced rows against pulled rows on note_id. That
|
||||||
|
answers "of the notes this surface chose, how many were opened", which the
|
||||||
|
top-level ratio averages away. It does not cross into `retrieval_logs`, so
|
||||||
|
the sentence above still holds.
|
||||||
|
|
||||||
Scoped to one user's own telemetry. There is no sharing model for a
|
Scoped to one user's own telemetry. There is no sharing model for a
|
||||||
retrieval log — it records what THIS user's agent asked for, including the
|
retrieval log — it records what THIS user's agent asked for, including the
|
||||||
query text — so an owner filter is the whole access rule here rather than a
|
query text — so an owner filter is the whole access rule here rather than a
|
||||||
@@ -214,23 +355,77 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
|||||||
"since": iso(since),
|
"since": iso(since),
|
||||||
"sources": {},
|
"sources": {},
|
||||||
"usage": {},
|
"usage": {},
|
||||||
|
"rule_usage": {},
|
||||||
"read_failed": False,
|
"read_failed": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
cleared = case(
|
zero = case((RetrievalLog.result_count == 0, 1), else_=0)
|
||||||
(
|
# THE NEAR-MISS POPULATION: calls that returned nothing BECAUSE THE BAR
|
||||||
(RetrievalLog.threshold.isnot(None))
|
# TURNED SOMETHING AWAY, and recorded what it was. Three conditions, and
|
||||||
& (RetrievalLog.top_score.isnot(None))
|
# the third was missing for one deploy (#3739).
|
||||||
& (RetrievalLog.top_score >= RetrievalLog.threshold),
|
#
|
||||||
1,
|
# Zero-result only: on a call that returned something,
|
||||||
),
|
# `best_available_score` equals `top_score` and adds nothing.
|
||||||
|
#
|
||||||
|
# Non-null only: rows written before #3670 genuinely do not know, and must
|
||||||
|
# not read as scoreless declines.
|
||||||
|
#
|
||||||
|
# AND NOT A REPEAT. A zero-result call is two unrelated events — the ranker
|
||||||
|
# found nothing above the bar, or it found only what this session had
|
||||||
|
# already been shown — and just the first says anything about the bar. That
|
||||||
|
# is the whole of #3497, and #3670 reintroduced the conflation one level up:
|
||||||
|
# the rule arms filter exclusions in PYTHON, after the search, so a rule
|
||||||
|
# that cleared the bar and was dropped as a repeat still reported a high
|
||||||
|
# `best_available_score` on a zero-result row. Live proof, first read after
|
||||||
|
# deploy: pre_tool_rule's near-miss max was 0.7457 while the lowest score it
|
||||||
|
# ever RETURNED was 0.7204 — a "rejection" that outscored acceptances.
|
||||||
|
#
|
||||||
|
# The NULL arm is principled, not permissive: `suppressed_count IS NULL`
|
||||||
|
# means the caller passed its exclusions INTO the search, which is exactly
|
||||||
|
# the case where the reported score is already post-exclusion and cannot be
|
||||||
|
# contaminated. Note arms stay measured; rule arms get cleaned.
|
||||||
|
#
|
||||||
|
# Deliberately conservative: a call carrying both a repeat and a lower
|
||||||
|
# genuine miss is dropped whole, losing that point. It undercounts; it
|
||||||
|
# cannot corrupt — the right way round for a number read against a bar.
|
||||||
|
#
|
||||||
|
# This also makes `near_misses.max < threshold` true BY CONSTRUCTION. An
|
||||||
|
# above-bar candidate that was not excluded would have been returned, so
|
||||||
|
# its call is not in this population at all.
|
||||||
|
declined = (
|
||||||
|
(RetrievalLog.result_count == 0)
|
||||||
|
& (RetrievalLog.best_available_score.isnot(None))
|
||||||
|
& (
|
||||||
|
RetrievalLog.suppressed_count.is_(None)
|
||||||
|
| (RetrievalLog.suppressed_count == 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
miss = case((declined, 1), else_=0)
|
||||||
|
# `best_available_score` only for those rows; NULL elsewhere, and
|
||||||
|
# percentile_cont ignores NULLs, so the distribution is over the declines
|
||||||
|
# alone without a second pass over the table.
|
||||||
|
miss_score = case((declined, RetrievalLog.best_available_score), else_=None)
|
||||||
|
# Three sums rather than one, because "not measured" and "measured as zero"
|
||||||
|
# are different answers and a single counter cannot hold both.
|
||||||
|
measured = case((RetrievalLog.suppressed_count.isnot(None), 1), else_=0)
|
||||||
|
supp_calls = case((RetrievalLog.suppressed_count > 0, 1), else_=0)
|
||||||
|
supp_zero = case(
|
||||||
|
((RetrievalLog.result_count == 0) & (RetrievalLog.suppressed_count > 0), 1),
|
||||||
else_=0,
|
else_=0,
|
||||||
)
|
)
|
||||||
zero = case((RetrievalLog.result_count == 0, 1), else_=0)
|
|
||||||
|
|
||||||
def pct(p: float):
|
def pct(p: float):
|
||||||
return func.percentile_cont(p).within_group(RetrievalLog.top_score.asc())
|
return func.percentile_cont(p).within_group(RetrievalLog.top_score.asc())
|
||||||
|
|
||||||
|
# Assigned inside the try below; named here so the readout can tell
|
||||||
|
# "this query failed" from "this window has no rows" (#2663).
|
||||||
|
by_source_rows = None
|
||||||
|
rule_rows = None
|
||||||
|
distinct_rules_surfaced = distinct_rules_pulled = 0
|
||||||
|
# None means the coverage read did not happen — distinct from a table with
|
||||||
|
# no rows, which is {"*": None}. Same reason `read_failed` exists.
|
||||||
|
note_complete = rule_complete = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
rows = (
|
rows = (
|
||||||
@@ -239,7 +434,6 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
|||||||
RetrievalLog.source,
|
RetrievalLog.source,
|
||||||
func.count().label("calls"),
|
func.count().label("calls"),
|
||||||
func.sum(zero).label("zero"),
|
func.sum(zero).label("zero"),
|
||||||
func.sum(cleared).label("cleared"),
|
|
||||||
pct(0.1), pct(0.5), pct(0.9),
|
pct(0.1), pct(0.5), pct(0.9),
|
||||||
func.min(RetrievalLog.top_score),
|
func.min(RetrievalLog.top_score),
|
||||||
func.max(RetrievalLog.top_score),
|
func.max(RetrievalLog.top_score),
|
||||||
@@ -247,6 +441,13 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
|||||||
func.percentile_cont(0.9).within_group(
|
func.percentile_cont(0.9).within_group(
|
||||||
RetrievalLog.duration_ms.asc()
|
RetrievalLog.duration_ms.asc()
|
||||||
),
|
),
|
||||||
|
func.sum(measured).label("measured"),
|
||||||
|
func.sum(supp_calls).label("supp_calls"),
|
||||||
|
func.sum(supp_zero).label("supp_zero"),
|
||||||
|
func.sum(miss).label("miss_calls"),
|
||||||
|
func.percentile_cont(0.5).within_group(miss_score.asc()),
|
||||||
|
func.percentile_cont(0.9).within_group(miss_score.asc()),
|
||||||
|
func.max(miss_score),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
RetrievalLog.created_at >= since,
|
RetrievalLog.created_at >= since,
|
||||||
@@ -255,8 +456,34 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
|||||||
.group_by(RetrievalLog.source)
|
.group_by(RetrievalLog.source)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
log_complete = await _complete_from(session, RetrievalLog, user_id)
|
||||||
for row in rows:
|
for row in rows:
|
||||||
out["sources"][row[0]] = _bucket(list(row[1:]))
|
source = row[0]
|
||||||
|
bucket = _bucket(list(row[1:]))
|
||||||
|
# Per SOURCE, not per table: retrieval_logs goes back months
|
||||||
|
# while any individual arm may be days old, and the table's
|
||||||
|
# age would vouch for an arm that has barely started.
|
||||||
|
bucket.update(_coverage(log_complete.get(source), since))
|
||||||
|
out["sources"][source] = bucket
|
||||||
|
|
||||||
|
# A source with rows in the table but NONE in this window would
|
||||||
|
# otherwise be absent from the readout — and absent is exactly how
|
||||||
|
# a source that never existed renders, so a surface that WAS
|
||||||
|
# recording and went silent is unreadable (#3720). That is #2663
|
||||||
|
# one level up: the failure that looks like the correct answer.
|
||||||
|
#
|
||||||
|
# Zero here is a real measurement, not a manufactured one. The
|
||||||
|
# all-time query proves the source was recording, and it made no
|
||||||
|
# calls across a window it fully covers — which is why no
|
||||||
|
# `covers_window` special case is needed: a source whose first row
|
||||||
|
# fell after `since` would have that row IN the window and already
|
||||||
|
# hold a bucket, so anything reaching here began before it.
|
||||||
|
for src, first_row in log_complete.items():
|
||||||
|
if src == "*" or first_row is None or src in out["sources"]:
|
||||||
|
continue
|
||||||
|
quiet = _bucket(list(_NO_ROWS_IN_WINDOW))
|
||||||
|
quiet.update(_coverage(first_row, since))
|
||||||
|
out["sources"][src] = quiet
|
||||||
|
|
||||||
# The corpus side, at its own grain. `ambient` mirrors
|
# The corpus side, at its own grain. `ambient` mirrors
|
||||||
# note_usage.usage_for_notes: an ambient surfacing was not a scored
|
# note_usage.usage_for_notes: an ambient surfacing was not a scored
|
||||||
@@ -283,6 +510,7 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
|||||||
.group_by(NoteUsageEvent.event, NoteUsageEvent.source)
|
.group_by(NoteUsageEvent.event, NoteUsageEvent.source)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
note_complete = await _complete_from(session, NoteUsageEvent, user_id)
|
||||||
|
|
||||||
# Distinct-note counts need their OWN queries, and this is not
|
# Distinct-note counts need their OWN queries, and this is not
|
||||||
# fussiness: count(distinct note_id) per (event, source) group
|
# fussiness: count(distinct note_id) per (event, source) group
|
||||||
@@ -310,6 +538,142 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
|
# Per-source pull-through, at the NOTE grain (#3311).
|
||||||
|
#
|
||||||
|
# The `urows` query above already groups by source and the loop
|
||||||
|
# below then throws the source away, so until now this readout
|
||||||
|
# could say what the corpus's overall pull-through was and nothing
|
||||||
|
# about WHICH surface earned it. The data was always here; only
|
||||||
|
# the aggregation discarded it.
|
||||||
|
#
|
||||||
|
# It cannot be had by grouping the PULLED rows by source: a pull
|
||||||
|
# records the door it came through (`mcp_get_note`), not the
|
||||||
|
# surface that put the record in front of the agent. Correlating
|
||||||
|
# those within a session is what #2085 ruled out — there is no
|
||||||
|
# session identity server-side and inventing one would mean
|
||||||
|
# threading a client-supplied token through every read path. The
|
||||||
|
# note grain answers the question without one: of the distinct
|
||||||
|
# notes surface X chose, how many did an agent open in this window?
|
||||||
|
#
|
||||||
|
# Guarded separately from the reads above, on #2663's actual
|
||||||
|
# lesson. That outage was a NOVEL SQL SHAPE the database rejected
|
||||||
|
# inside a broad except. This join is the novel shape here, and a
|
||||||
|
# failure in it must not take down two readouts that already work.
|
||||||
|
try:
|
||||||
|
pulled_ids = (
|
||||||
|
select(NoteUsageEvent.note_id)
|
||||||
|
.where(
|
||||||
|
NoteUsageEvent.created_at >= since,
|
||||||
|
NoteUsageEvent.user_id == user_id,
|
||||||
|
NoteUsageEvent.event == PULLED,
|
||||||
|
# autoescape because `_` is a LIKE wildcard: a bare
|
||||||
|
# like("mcp_%") also matches "mcpX…". The Python half
|
||||||
|
# of this readout uses str.startswith and has no such
|
||||||
|
# hazard; this is the SQL half's version of it.
|
||||||
|
NoteUsageEvent.source.startswith("mcp_", autoescape=True),
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
surfaced_pairs = (
|
||||||
|
select(NoteUsageEvent.source, NoteUsageEvent.note_id)
|
||||||
|
.where(
|
||||||
|
NoteUsageEvent.created_at >= since,
|
||||||
|
NoteUsageEvent.user_id == user_id,
|
||||||
|
NoteUsageEvent.event == SURFACED,
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
# DISTINCT on (source, note_id) FIRST, which is what lets the
|
||||||
|
# outer aggregate be a plain count(): the pairs are already
|
||||||
|
# unique, so the left join cannot multiply them and no
|
||||||
|
# count(DISTINCT) is needed to undo damage that never happens.
|
||||||
|
by_source_rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(
|
||||||
|
surfaced_pairs.c.source,
|
||||||
|
func.count().label("notes_surfaced"),
|
||||||
|
func.count(pulled_ids.c.note_id).label("notes_pulled"),
|
||||||
|
)
|
||||||
|
.select_from(
|
||||||
|
surfaced_pairs.outerjoin(
|
||||||
|
pulled_ids,
|
||||||
|
pulled_ids.c.note_id == surfaced_pairs.c.note_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.group_by(surfaced_pairs.c.source)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("per-source pull-through read failed", exc_info=True)
|
||||||
|
by_source_rows = None
|
||||||
|
|
||||||
|
# Rules, at their own grain and in their own block (milestone 333).
|
||||||
|
#
|
||||||
|
# Guarded separately from the reads above for the reason `by_source`
|
||||||
|
# is: this table is NEW, and an instance running upgraded code
|
||||||
|
# against un-migrated schema would otherwise take down two readouts
|
||||||
|
# that work perfectly in order to report a third that cannot.
|
||||||
|
#
|
||||||
|
# The queries themselves are the note block's shapes, not novel
|
||||||
|
# ones — a group-by on two indexed columns and two count(distinct).
|
||||||
|
# The distinct counts need their own queries for the same reason
|
||||||
|
# the note ones do: count(distinct rule_id) per group cannot be
|
||||||
|
# summed across groups without double-counting a rule two sources
|
||||||
|
# both touched.
|
||||||
|
try:
|
||||||
|
rule_rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(
|
||||||
|
RuleUsageEvent.event,
|
||||||
|
RuleUsageEvent.source,
|
||||||
|
func.count().label("n"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
RuleUsageEvent.created_at >= since,
|
||||||
|
RuleUsageEvent.user_id == user_id,
|
||||||
|
)
|
||||||
|
.group_by(RuleUsageEvent.event, RuleUsageEvent.source)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
rule_complete = await _complete_from(
|
||||||
|
session, RuleUsageEvent, user_id,
|
||||||
|
)
|
||||||
|
# The rows carry `source`, so the ranked/ambient split is done
|
||||||
|
# below rather than in SQL — the bulk surfaces started emitting
|
||||||
|
# on 2026-09-03 (#3473), so there IS an ambient class now.
|
||||||
|
#
|
||||||
|
# `distinct_rules_surfaced` deliberately counts BOTH classes. It
|
||||||
|
# answers "how many distinct rules did this install put in front
|
||||||
|
# of an agent at all", which is the denominator for dead weight
|
||||||
|
# — and a rule delivered by the preload a hundred times and
|
||||||
|
# never opened is the most important case that question has.
|
||||||
|
distinct_rules_surfaced = (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count(func.distinct(RuleUsageEvent.rule_id)))
|
||||||
|
.where(
|
||||||
|
RuleUsageEvent.created_at >= since,
|
||||||
|
RuleUsageEvent.user_id == user_id,
|
||||||
|
RuleUsageEvent.event == RULE_SURFACED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
distinct_rules_pulled = (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count(func.distinct(RuleUsageEvent.rule_id)))
|
||||||
|
.where(
|
||||||
|
RuleUsageEvent.created_at >= since,
|
||||||
|
RuleUsageEvent.user_id == user_id,
|
||||||
|
RuleUsageEvent.event == RULE_PULLED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("rule usage read failed", exc_info=True)
|
||||||
|
rule_rows = None
|
||||||
|
distinct_rules_surfaced = distinct_rules_pulled = 0
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("retrieval summary read failed", exc_info=True)
|
logger.warning("retrieval summary read failed", exc_info=True)
|
||||||
out["read_failed"] = True
|
out["read_failed"] = True
|
||||||
@@ -350,5 +714,124 @@ async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
|||||||
round(usage["pulled_by_agent"] / usage["surfaced"], 4)
|
round(usage["pulled_by_agent"] / usage["surfaced"], 4)
|
||||||
if usage["surfaced"] else None
|
if usage["surfaced"] else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The same question, per surface — which is the one the top-level ratio
|
||||||
|
# cannot answer. A corpus average of 0.05 is compatible with one surface
|
||||||
|
# earning its noise and another producing none, and tuning a threshold
|
||||||
|
# needs to know which.
|
||||||
|
#
|
||||||
|
# UPPER BOUND, and say so where it will be read: a pull records the door,
|
||||||
|
# not the surface that led to it, so a note surfaced by two surfaces and
|
||||||
|
# opened once counts as pulled for both. Attribution would need the session
|
||||||
|
# identity #2085 declined to invent. The bound is still decisive in the
|
||||||
|
# direction that matters — a surface reading near zero here is not being
|
||||||
|
# flattered by the double-count.
|
||||||
|
if by_source_rows is None:
|
||||||
|
usage["by_source"] = {}
|
||||||
|
# Distinct from an empty window, for the same reason `read_failed` is.
|
||||||
|
usage["by_source_failed"] = True
|
||||||
|
else:
|
||||||
|
by_source: dict[str, dict] = {}
|
||||||
|
for source, n_surfaced, n_pulled in by_source_rows:
|
||||||
|
n_surfaced, n_pulled = int(n_surfaced or 0), int(n_pulled or 0)
|
||||||
|
ambient = source in AMBIENT_SOURCES
|
||||||
|
by_source[source] = {
|
||||||
|
"notes_surfaced": n_surfaced,
|
||||||
|
"notes_pulled": n_pulled,
|
||||||
|
# None rather than a number on an ambient surface: nothing
|
||||||
|
# CHOSE those records, so "surfaced often, opened never" is not
|
||||||
|
# a judgment about them. The counts stay visible; the ratio
|
||||||
|
# that would be misread does not.
|
||||||
|
"pull_through": (
|
||||||
|
None if ambient or not n_surfaced
|
||||||
|
else round(n_pulled / n_surfaced, 4)
|
||||||
|
),
|
||||||
|
"ambient": ambient,
|
||||||
|
}
|
||||||
|
usage["by_source"] = by_source
|
||||||
|
|
||||||
|
# The SECTION's coverage, from the latest source to start recording — a
|
||||||
|
# figure that sums several sources is complete only once every one of them
|
||||||
|
# was being written. `_complete_from` computes that as "*".
|
||||||
|
usage.update(_coverage((note_complete or {}).get("*"), since))
|
||||||
out["usage"] = usage
|
out["usage"] = usage
|
||||||
|
|
||||||
|
# ── Rules, deliberately a SEPARATE block ────────────────────────────
|
||||||
|
#
|
||||||
|
# Not folded into `usage`, for two reasons and the second is the one that
|
||||||
|
# bites. The corpora differ by orders of magnitude — a few dozen eligible
|
||||||
|
# rules against thousands of notes — so one blended ratio would be the note
|
||||||
|
# ratio with a little noise on it, and the rule arm's own behaviour would
|
||||||
|
# be undetectable inside it. And `usage` is what existing callers already
|
||||||
|
# read: silently changing what it counts would move a number people have
|
||||||
|
# been comparing across windows, without telling them it now measures
|
||||||
|
# something else.
|
||||||
|
#
|
||||||
|
# `ambient` now carries the bulk deliveries — the SessionStart preload,
|
||||||
|
# `list_always_on_rules`, and every `rules_payload` surface (#3473). Before
|
||||||
|
# they emitted, this block had no ambient key and said the absence was a
|
||||||
|
# fact about the data. It was, and it was also the thing that made the
|
||||||
|
# always-on set impossible to judge: the largest rule surface in the
|
||||||
|
# product was the one surface its own scoreboard could not see.
|
||||||
|
#
|
||||||
|
# READ THE TWO SEPARATELY, ALWAYS. `surfaced` is a claim a ranker made and
|
||||||
|
# a pull can settle. `ambient` is a delivery nobody chose, so a high count
|
||||||
|
# says the set is large and resident, never that it is useful.
|
||||||
|
rule_usage = {
|
||||||
|
"surfaced": 0, "ambient": 0,
|
||||||
|
"pulled": 0, "pulled_by_agent": 0, "pulled_by_human": 0,
|
||||||
|
"distinct_rules_surfaced": int(distinct_rules_surfaced or 0),
|
||||||
|
"distinct_rules_pulled": int(distinct_rules_pulled or 0),
|
||||||
|
}
|
||||||
|
if rule_rows is None:
|
||||||
|
# The FLAG is added, the shape is kept — matching `by_source_failed`
|
||||||
|
# one block up. A caller that renders this must not have to choose
|
||||||
|
# between crashing on a missing key and quietly showing zeros it has no
|
||||||
|
# right to: the keys let it render, and the flag tells it the zeros are
|
||||||
|
# "we could not find out" rather than "nothing happened" (#2663).
|
||||||
|
rule_usage["rule_usage_failed"] = True
|
||||||
|
else:
|
||||||
|
for event, source, n in rule_rows:
|
||||||
|
n = int(n)
|
||||||
|
if event == RULE_SURFACED:
|
||||||
|
# One definition of ranked-vs-ambient, imported rather than
|
||||||
|
# restated — the per-rule badge readout reads the same
|
||||||
|
# predicate, and two spellings of "what counts as surfaced" is
|
||||||
|
# precisely the uneven wiring #3246 found across this system.
|
||||||
|
if is_ambient(source):
|
||||||
|
rule_usage["ambient"] += n
|
||||||
|
else:
|
||||||
|
rule_usage["surfaced"] += n
|
||||||
|
elif event == RULE_PULLED:
|
||||||
|
rule_usage["pulled"] += n
|
||||||
|
# Same split, and it carries MORE weight here than for notes.
|
||||||
|
# The arm's whole claim is "this rule may apply to what you are
|
||||||
|
# writing", and only an agent opening it says the claim landed.
|
||||||
|
# A person browsing the rule list says nothing about the hint.
|
||||||
|
if source.startswith("mcp_"):
|
||||||
|
rule_usage["pulled_by_agent"] += n
|
||||||
|
else:
|
||||||
|
rule_usage["pulled_by_human"] += n
|
||||||
|
|
||||||
|
# None, not 0.0, when nothing was surfaced — matching the note block. A
|
||||||
|
# ratio of zero asserts "we showed rules and none were opened"; with an
|
||||||
|
# empty numerator AND denominator that is a claim the data does not
|
||||||
|
# support, and it is the reading that would make a brand-new install look
|
||||||
|
# like a broken one.
|
||||||
|
#
|
||||||
|
# RANKED SURFACINGS ONLY in the denominator, and this is the load-bearing
|
||||||
|
# line of the whole change. Pull-through asks "was that hint any use", and
|
||||||
|
# only a surface that CHOSE what it showed can be judged by it. Folding the
|
||||||
|
# preload in would divide the same pulls by a number that grows with every
|
||||||
|
# session and every rule added to the resident set — so enlarging the
|
||||||
|
# always-on set would DEPRESS the arm's measured precision, and trimming it
|
||||||
|
# would flatter it, neither for any reason to do with the arm. The ambient
|
||||||
|
# count sits beside it, unaveraged, and is read as size rather than skill.
|
||||||
|
rule_usage["pull_through"] = (
|
||||||
|
round(rule_usage["pulled_by_agent"] / rule_usage["surfaced"], 4)
|
||||||
|
if rule_usage["surfaced"] else None
|
||||||
|
)
|
||||||
|
rule_usage.update(_coverage((rule_complete or {}).get("*"), since))
|
||||||
|
out["rule_usage"] = rule_usage
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""Rule usage telemetry — did a surfaced rule ever get read?
|
||||||
|
|
||||||
|
The sibling of `note_usage`, for the one retrieval surface in Scribe that
|
||||||
|
could not be measured at all.
|
||||||
|
|
||||||
|
Two event streams, deliberately independent:
|
||||||
|
|
||||||
|
- SURFACED: the write-path standing-rule arm put this rule in front of the
|
||||||
|
agent, unbidden, during a write.
|
||||||
|
- PULLED: someone then opened it in full (`get_rule`, or the REST detail
|
||||||
|
route).
|
||||||
|
|
||||||
|
WHY THIS ARM AND NOT ANOTHER. Every other surface declines most of the time —
|
||||||
|
`write_path` returns nothing on 78% of calls, `reuse_slot` on 79%, auto-inject
|
||||||
|
on 39%. The rule arm APPEARED never to have returned nothing (#3311), and this
|
||||||
|
docstring used to put that forward as the puzzle worth measuring: "either a
|
||||||
|
perfectly tuned surface or a bar it cannot fail to clear".
|
||||||
|
|
||||||
|
It was neither, and the correction belongs here rather than being quietly
|
||||||
|
deleted. The arm wrote its `retrieval_logs` row only on calls that FOUND
|
||||||
|
something (#3497), so `zero_result_calls` sat at 0 and `cleared_threshold` at
|
||||||
|
`calls` because of the shape of the code — at any threshold whatsoever. A
|
||||||
|
statistic that could not vary was read as a finding about the corpus. It is the
|
||||||
|
#2663 failure mode one level up: there the broken readout was a zero, here it
|
||||||
|
was a hundred percent, which is far better camouflage.
|
||||||
|
|
||||||
|
The reason to measure this arm survives the correction, and is stronger for it.
|
||||||
|
`retrieval_logs` records what the ranker scored, never whether the hint was any
|
||||||
|
use, so even an honest clear-rate would not settle the question. The ratio these
|
||||||
|
two streams produce is the missing half, and without it any threshold change is
|
||||||
|
a number picked off a histogram.
|
||||||
|
|
||||||
|
Design notes, mirroring `note_usage`:
|
||||||
|
- Writes are fire-and-forget through `background.spawn`, so telemetry never
|
||||||
|
adds latency to — or can break — the surface it observes. This module does
|
||||||
|
NOT carry its own copy of the strong-reference dance; `background` is the
|
||||||
|
one place that gets it right, and a fourth copy is how one of them drifts.
|
||||||
|
- Failures degrade, but never SILENTLY. `report_telemetry_failure` logs at
|
||||||
|
WARNING and drops one AppLog row per process per site. #2663 is the record
|
||||||
|
of this exact subsystem class running at zero for weeks — indistinguishable
|
||||||
|
from "nobody uses this" — because every failure went to `logger.debug`.
|
||||||
|
- Reads (`usage_for_rules`) are awaited and aggregated in one round-trip for
|
||||||
|
a whole page, never per row.
|
||||||
|
|
||||||
|
AMBIENT VS RANKED. The note twin splits ranked surfacings from ambient ones
|
||||||
|
because `enter_project` and the skill sync put records in front of the agent
|
||||||
|
without choosing them, and counting those as surfacings makes recency read as
|
||||||
|
popularity (#2477). Rules have exactly that shape: the SessionStart preload,
|
||||||
|
`list_always_on_rules`, and every `rules_payload` surface hand over the whole
|
||||||
|
applicable set at once, chosen by nobody.
|
||||||
|
|
||||||
|
Until 2026-09-03 those bulk surfaces emitted nothing, and this module said so —
|
||||||
|
"an empty `AMBIENT_SOURCES` would be machinery pretending to a distinction the
|
||||||
|
data does not yet contain". True as far as it went, but it had a consequence
|
||||||
|
worth naming, because it is the reason the bucket exists now: the always-on
|
||||||
|
set's token cost was certain and its usefulness was UNFALSIFIABLE, permanently
|
||||||
|
and by construction. The one surface whose value was actually in question was
|
||||||
|
the one surface exempt from the scoreboard that judges every other.
|
||||||
|
|
||||||
|
They emit now. The split is the readout-level change the old note promised — a
|
||||||
|
`case()`, no migration, because `event` and `source` are plain Text with no
|
||||||
|
CHECK constraint. `source` stays granular so a reader can still tell the
|
||||||
|
preload from `enter_project` from the ranked arm.
|
||||||
|
|
||||||
|
WHY THIS NAMES THE RANKED SOURCES AND THE TWIN NAMES THE AMBIENT ONES. A
|
||||||
|
deliberate divergence, on the failure mode rather than on symmetry. Both shapes
|
||||||
|
fail silently when someone adds a surface and forgets the list, so the question
|
||||||
|
is which list changes more often — and here it is emphatically the ambient one:
|
||||||
|
there are TWO ranked rule sources (the write-path arm and the pre-tool arm)
|
||||||
|
against the seven bulk ones the preload alone contributes. Ranked sources are
|
||||||
|
added when somebody builds a ranker, which is rare and deliberate; bulk ones
|
||||||
|
appear whenever a surface hands rules over, which is most of them. Naming the
|
||||||
|
rare, slow-moving half means a newly-added bulk surface defaults to
|
||||||
|
`ambient`, which merely under-counts it, instead of defaulting to `ranked`,
|
||||||
|
which would quietly pad the pull-through denominator with surfacings nobody
|
||||||
|
chose and make the arm look imprecise. Same argument #3191 and #3430 make
|
||||||
|
against hand-kept lists: keep the list that must be remembered as short and as
|
||||||
|
slow-moving as possible.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import case, func, select
|
||||||
|
|
||||||
|
from scribe.models import async_session
|
||||||
|
from scribe.models.base import iso
|
||||||
|
from scribe.models.rule_usage import PULLED, SURFACED, RuleUsageEvent
|
||||||
|
from scribe.services.background import report_telemetry_failure, spawn
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# The surfaces that CHOSE the rules they showed. Everything else is ambient —
|
||||||
|
# see the module docstring for why the rare half is the half that gets named.
|
||||||
|
#
|
||||||
|
# Membership is the whole definition of the pull-through denominator: a ranked
|
||||||
|
# surfacing is a claim ("this rule may apply to what you are doing") that a pull
|
||||||
|
# can confirm or refute, while an ambient one is a delivery nobody decided on.
|
||||||
|
# Add a source here only when a ranker picked it.
|
||||||
|
RANKED_SOURCES = ("write_path_rule", "pre_tool_rule")
|
||||||
|
|
||||||
|
|
||||||
|
def is_ambient(source: str) -> bool:
|
||||||
|
"""Was this surfacing a bulk delivery rather than a ranked choice?
|
||||||
|
|
||||||
|
One definition, read by both the per-rule badge readout and the aggregate
|
||||||
|
in `retrieval_telemetry` — the two used to be able to disagree about what
|
||||||
|
"surfaced" counted, which is the class of drift #3246 found across the
|
||||||
|
rules system.
|
||||||
|
|
||||||
|
Sync and pure, per the service canon (#2860), but deliberately PUBLIC where
|
||||||
|
that canon says such helpers stay `_private`. The departure is the point:
|
||||||
|
a module-private copy in each caller is exactly the second definition this
|
||||||
|
exists to prevent.
|
||||||
|
"""
|
||||||
|
return source not in RANKED_SOURCES
|
||||||
|
|
||||||
|
|
||||||
|
async def _report_failure(site: str) -> None:
|
||||||
|
await report_telemetry_failure("rule_usage", site)
|
||||||
|
|
||||||
|
|
||||||
|
async def _insert_events(rows: list[dict]) -> None:
|
||||||
|
"""Persist usage rows. Best-effort: failures degrade, visibly."""
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
session.add_all([RuleUsageEvent(**row) for row in rows])
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await _report_failure("write")
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule(rows: list[dict]) -> None:
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
spawn(_insert_events(rows), site="rule_usage_write")
|
||||||
|
|
||||||
|
|
||||||
|
def record_rule_surfaced(
|
||||||
|
*, user_id: int | None, rule_ids: list[int] | set[int], source: str
|
||||||
|
) -> None:
|
||||||
|
"""Fire-and-forget: record that these rules were shown to the agent.
|
||||||
|
|
||||||
|
Takes the whole delivery at once — one insert per surfacing event, not per
|
||||||
|
rule — because a hint is a single decision and its rows should land
|
||||||
|
together.
|
||||||
|
|
||||||
|
Record what was actually SHOWN, never what was considered. For the ranked
|
||||||
|
arm that means the post-filter hits: it drops what the session already
|
||||||
|
holds (`exclude_rule_ids`) before it speaks, and a rule considered and not
|
||||||
|
shown was not surfaced. Counting those would inflate the denominator with
|
||||||
|
claims the agent never saw, which reads as a precision problem the arm does
|
||||||
|
not have.
|
||||||
|
|
||||||
|
Bulk surfaces pass their whole delivered set, which is the same rule read
|
||||||
|
from the other end — everything in a preload IS shown. `source` is what
|
||||||
|
separates the two afterwards (see `RANKED_SOURCES`); this function does not
|
||||||
|
care which kind it is recording.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"user_id": user_id,
|
||||||
|
"rule_id": int(rid),
|
||||||
|
"event": SURFACED,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
for rid in rule_ids
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
logger.debug("rule usage payload build failed", exc_info=True)
|
||||||
|
return
|
||||||
|
_schedule(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def record_rule_pulled(*, user_id: int | None, rule_id: int, source: str) -> None:
|
||||||
|
"""Fire-and-forget: record that a rule was opened in full.
|
||||||
|
|
||||||
|
A PULL is somebody choosing to open one record. `list_always_on_rules` and
|
||||||
|
`enter_project` are NOT pulls — they are bulk resident loads that hand over
|
||||||
|
every applicable rule at once, and counting them would swamp the signal
|
||||||
|
with the very ambient delivery the ratio exists to distinguish from.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"user_id": user_id,
|
||||||
|
"rule_id": int(rule_id),
|
||||||
|
"event": PULLED,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
logger.debug("rule usage payload build failed", exc_info=True)
|
||||||
|
return
|
||||||
|
_schedule(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def empty_rule_usage() -> dict:
|
||||||
|
"""The zero readout — what a rule with no recorded events looks like.
|
||||||
|
|
||||||
|
Callers render this shape unconditionally, so a rule predating the table
|
||||||
|
reads as "never surfaced, never pulled" rather than as a missing key. That
|
||||||
|
distinction matters more here than for notes: every rule in an install
|
||||||
|
predates this table, so for a while "no events" is the normal state and it
|
||||||
|
must not look like a broken readout.
|
||||||
|
|
||||||
|
`surfaced_count` is RANKED surfacings only; `ambient_count` is the bulk
|
||||||
|
deliveries (see `RANKED_SOURCES`). The split is what keeps the badge's
|
||||||
|
"shown often, opened never → dead weight" reading honest: every rule in an
|
||||||
|
always-on set is delivered every session, so an unsplit counter would rank
|
||||||
|
the resident set as the most-surfaced rules in the install purely for being
|
||||||
|
resident.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"surfaced_count": 0,
|
||||||
|
"ambient_count": 0,
|
||||||
|
"pull_count": 0,
|
||||||
|
"last_surfaced_at": None,
|
||||||
|
"last_pulled_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def usage_for_rules(rule_ids: list[int]) -> dict[int, dict]:
|
||||||
|
"""Aggregate usage for a set of rules: {rule_id: {counts + timestamps}}.
|
||||||
|
|
||||||
|
One GROUP BY for the whole page rather than a query per row — this feeds a
|
||||||
|
list view, so the per-row shape would be N+1 by construction. Rules with no
|
||||||
|
events come back with `empty_rule_usage()`, so the caller never has to tell
|
||||||
|
"no events" from "not in the result".
|
||||||
|
"""
|
||||||
|
ids = [int(r) for r in rule_ids]
|
||||||
|
out: dict[int, dict] = {rid: empty_rule_usage() for rid in ids}
|
||||||
|
if not ids:
|
||||||
|
return out
|
||||||
|
|
||||||
|
# Classified in SQL so the group stays small: per rule we get at most
|
||||||
|
# (surfaced-ranked, surfaced-ambient, pulled) rather than a row per distinct
|
||||||
|
# source. ONE labelled expression, bound to a variable and reused in the
|
||||||
|
# GROUP BY — a second `case()` instance there renders its own expanding-IN
|
||||||
|
# bind names under asyncpg, so the database sees two DIFFERENT expressions
|
||||||
|
# and rejects the query with a GroupingError. The note twin carries the
|
||||||
|
# same warning for the same reason, and #2663 is what it cost: the
|
||||||
|
# rejection was swallowed and every counter read zero in production while
|
||||||
|
# the writes were landing fine.
|
||||||
|
ambient = case(
|
||||||
|
(RuleUsageEvent.source.notin_(RANKED_SOURCES), True),
|
||||||
|
else_=False,
|
||||||
|
).label("ambient")
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(
|
||||||
|
RuleUsageEvent.rule_id,
|
||||||
|
RuleUsageEvent.event,
|
||||||
|
func.count().label("n"),
|
||||||
|
func.max(RuleUsageEvent.created_at).label("last_at"),
|
||||||
|
ambient,
|
||||||
|
)
|
||||||
|
.where(RuleUsageEvent.rule_id.in_(ids))
|
||||||
|
.group_by(
|
||||||
|
RuleUsageEvent.rule_id,
|
||||||
|
RuleUsageEvent.event,
|
||||||
|
ambient,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
except Exception:
|
||||||
|
# A telemetry readout must not be able to break the list it decorates —
|
||||||
|
# but it must say it failed, or a broken readout is indistinguishable
|
||||||
|
# from a corpus nobody uses (#2663).
|
||||||
|
await _report_failure("readout")
|
||||||
|
return out
|
||||||
|
|
||||||
|
for rule_id, event, n, last_at, is_amb in rows:
|
||||||
|
slot = out.get(int(rule_id))
|
||||||
|
if slot is None:
|
||||||
|
continue
|
||||||
|
if event == SURFACED and is_amb:
|
||||||
|
slot["ambient_count"] = int(n)
|
||||||
|
elif event == SURFACED:
|
||||||
|
slot["surfaced_count"] = int(n)
|
||||||
|
slot["last_surfaced_at"] = iso(last_at)
|
||||||
|
elif event == PULLED:
|
||||||
|
# Pulls are pulls regardless of what surfaced the rule — "did
|
||||||
|
# anyone ever open this?" does not depend on how it was found. Both
|
||||||
|
# halves accumulate, so this ADDS rather than assigns: a rule can
|
||||||
|
# now be pulled after a ranked hint and after a preload, and the
|
||||||
|
# split arrives as two rows.
|
||||||
|
slot["pull_count"] = slot["pull_count"] + int(n)
|
||||||
|
latest = iso(last_at)
|
||||||
|
if latest and (slot["last_pulled_at"] or "") < latest:
|
||||||
|
slot["last_pulled_at"] = latest
|
||||||
|
return out
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user