Compare commits
131
Commits
v26.08.22.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
63036ed52e | ||
|
|
2e39dca9cf | ||
|
|
69d93898d9 | ||
|
|
5aabc31ee7 | ||
|
|
4be1eaecf6 | ||
|
|
e2e64b94c0 | ||
|
|
3345be84d1 | ||
|
|
35c632f834 | ||
|
|
b97f57ee7f | ||
|
|
874f7cacdb | ||
|
|
91b34619f9 | ||
|
|
3d4f5be711 | ||
|
|
15659e2c57 | ||
|
|
88e9c0b0bd | ||
|
|
c83bedf3be | ||
|
|
9d7485df2d | ||
|
|
410d616c22 | ||
|
|
469b43f222 | ||
|
|
c61925be76 | ||
|
|
e08e999406 | ||
|
|
0e5aed58a9 | ||
|
|
02c1e37620 | ||
|
|
4585cda3ff | ||
|
|
cd9aa87aa4 | ||
|
|
6ada97bb0b | ||
|
|
95a37318fc | ||
|
|
682bea5257 | ||
|
|
8b60d552d2 | ||
|
|
ffb7a0fe38 | ||
|
|
6ddb8bf859 | ||
|
|
67874268bb | ||
|
|
c58529718b | ||
|
|
879ef3053e | ||
|
|
a97547fbc6 | ||
|
|
6b1f5e8031 | ||
|
|
a8f35e465e | ||
|
|
64bfa5725f | ||
|
|
446d6da0d7 | ||
|
|
136dbc16a6 | ||
|
|
6871c25445 | ||
|
|
57f6982f56 | ||
|
|
df18e897af | ||
|
|
4179f3e560 | ||
|
|
c5faaf38fb | ||
|
|
649fdff2ea | ||
|
|
9190fa0f10 | ||
|
|
6fb0cb38a5 | ||
|
|
cba542a3ec | ||
|
|
c28c87c39e | ||
|
|
8664d8ad14 | ||
|
|
ffbdf19116 | ||
|
|
dffbf43d84 | ||
|
|
31383bcebe | ||
|
|
0ab94b2a00 | ||
|
|
9c00a4b6e1 | ||
|
|
144192754c | ||
|
|
85111442a6 | ||
|
|
a2b377b74d | ||
|
|
590203a293 | ||
|
|
449f437048 | ||
|
|
48f0630dab | ||
|
|
a72605de8f | ||
|
|
4fa8158329 | ||
|
|
10687120a5 | ||
|
|
b88225eeb3 | ||
|
|
5925335ca0 | ||
|
|
2324c15418 | ||
|
|
bb242ca566 |
@@ -46,8 +46,6 @@ on:
|
||||
- "alembic/**"
|
||||
- "alembic.ini"
|
||||
- "Dockerfile"
|
||||
- "assets/**"
|
||||
- "fable-mcp/**"
|
||||
# The plugin ships straight from this repo — installs fetch it via
|
||||
# .claude-plugin/marketplace.json, NOT from the image. So a push here is
|
||||
# the release, with no build step in between. Omitting these paths meant
|
||||
@@ -279,6 +277,21 @@ jobs:
|
||||
env:
|
||||
UV_PROJECT_ENVIRONMENT: /opt/venv
|
||||
run: uv sync --locked --extra dev
|
||||
# Standing answers to the checks carried by rules 81 and 79 — two facts
|
||||
# about THIS runner that conditional rules assert as fact, and that
|
||||
# otherwise need a throwaway job to confirm (#3237). Printing them on
|
||||
# every integration run makes the next rulebook sweep a log read.
|
||||
# Rule 80's evidence is the container listing the next step already
|
||||
# prints. Every command is guarded: a diagnostic that can break the lane
|
||||
# it observes is worse than no diagnostic.
|
||||
- name: Runner facts (rules 79 and 81)
|
||||
run: |
|
||||
echo "--- rule 81: which shell runs a run: step ---"
|
||||
readlink -f /bin/sh || echo "/bin/sh: not a symlink"
|
||||
ps -p $$ -o comm= || true
|
||||
echo "--- rule 79: is a service reachable by its hostname yet? ---"
|
||||
getent hosts postgres \
|
||||
|| echo "no — 'postgres' does not resolve; the bridge-IP lookup is still required"
|
||||
- name: Integration suite (resolve service IP, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
@@ -289,8 +302,9 @@ jobs:
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
test -n "$PG_IP"
|
||||
export DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test"
|
||||
# Wait for Postgres to accept connections (busybox sh — the runner
|
||||
# default — has no bash /dev/tcp, so use Python).
|
||||
# Wait for Postgres to accept connections. The run: shell is dash
|
||||
# (/bin/sh -> /usr/bin/dash on this Debian-based image, confirmed by
|
||||
# the step above) — no bash /dev/tcp, so use Python.
|
||||
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
||||
import socket, sys, time
|
||||
for _ in range(30):
|
||||
@@ -327,6 +341,14 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# Rule 149 asks for this on any job deriving the version NAME. The
|
||||
# name here comes from HEAD's commit TIME, which a depth-1 clone
|
||||
# already has — but the rule states it unconditionally because the
|
||||
# failure it guards is silent (a too-low value, every lane green),
|
||||
# and a later change to how the name is derived would inherit the
|
||||
# landmine rather than the guard.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate image tags and version
|
||||
id: tags
|
||||
@@ -339,7 +361,27 @@ jobs:
|
||||
# the runner log on commit 2a374d9.
|
||||
run: |
|
||||
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
||||
BUILD_VERSION="dev"
|
||||
|
||||
# THREE VALUES, NEVER FOLDED TOGETHER (rule 149). Until 2026-08-31
|
||||
# BUILD_VERSION was the CHANNEL — "dev" / "main" / the tag — so the
|
||||
# image self-reported {"version":"main"}, a channel name where a
|
||||
# build identifier belongs. That cost a debugging session: with the
|
||||
# deploy misbehaving, nothing on the running instance could say
|
||||
# which commit was serving it.
|
||||
|
||||
# 1. ORDERING KEY — BUILD time, monotonic by construction. Minutes
|
||||
# since 2020-01-01. Never a commit count (not monotonic across
|
||||
# branches) and never commit time (goes DOWN when an older
|
||||
# commit is rebuilt).
|
||||
BUILD_KEY=$(( ( $(date -u +%s) - 1577836800 ) / 60 ))
|
||||
|
||||
# 2. NAME — COMMIT time, so the same source reports the same string
|
||||
# on every lane and the channel is the only thing that differs.
|
||||
COMMIT_TS=$(git log --format=%ct -1 HEAD)
|
||||
BUILD_NAME=$(date -u -d "@$COMMIT_TS" +%Y.%m.%d.%H%M)
|
||||
|
||||
# 3. CHANNEL — its own value. Never a suffix, never a segment.
|
||||
CHANNEL="dev"
|
||||
case "${{ github.ref }}" in
|
||||
refs/heads/dev)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
||||
@@ -348,15 +390,17 @@ jobs:
|
||||
# main IS the production line: publish :latest (plus the :<sha>
|
||||
# set above). No separate :main tag.
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
||||
BUILD_VERSION="main"
|
||||
CHANNEL="stable"
|
||||
;;
|
||||
refs/tags/*)
|
||||
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
||||
BUILD_VERSION="${{ github.ref_name }}"
|
||||
CHANNEL="stable"
|
||||
;;
|
||||
esac
|
||||
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "build_name=$BUILD_NAME" >> $GITHUB_OUTPUT
|
||||
echo "build_key=$BUILD_KEY" >> $GITHUB_OUTPUT
|
||||
echo "channel=$CHANNEL" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Free disk space
|
||||
# Self-hosted runner housekeeping. Two-step cleanup:
|
||||
@@ -386,7 +430,15 @@ jobs:
|
||||
push: true
|
||||
provenance: false
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
|
||||
# All three, plus the commit — rule 145: the registry's identity for
|
||||
# a build (:<sha>) and the artifact's identity for itself must
|
||||
# agree, and they can only be checked against each other if the
|
||||
# artifact says which commit it is.
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.tags.outputs.build_name }}
|
||||
BUILD_KEY=${{ steps.tags.outputs.build_key }}
|
||||
BUILD_CHANNEL=${{ steps.tags.outputs.channel }}
|
||||
BUILD_COMMIT=${{ github.sha }}
|
||||
# Registry-backed layer cache. Pull from :cache to prime
|
||||
# BuildKit, push updated layers back to :cache so the next
|
||||
# build starts warm even if the runner's local cache was
|
||||
|
||||
+21
-2
@@ -41,10 +41,29 @@ COPY alembic/ alembic/
|
||||
# Ensure Python finds the source tree (where static files live) before site-packages
|
||||
ENV PYTHONPATH=/app/src
|
||||
|
||||
# Version is injected at build time via --build-arg BUILD_VERSION=YY.MM.DD.N
|
||||
# Falls back to "dev" for local / untagged builds
|
||||
# THREE VALUES, NEVER FOLDED TOGETHER (rule 149), plus the commit.
|
||||
#
|
||||
# BUILD_VERSION is the NAME (YYYY.MM.DD.HHMM, from COMMIT time) — the same
|
||||
# string on every lane for the same source, so it answers "is this the same
|
||||
# code?" rather than "which lane built it?".
|
||||
# BUILD_KEY is the ORDERING KEY (minutes since 2020-01-01, from BUILD time) —
|
||||
# the only value anything may compare to decide what is newer.
|
||||
# BUILD_CHANNEL is its own field. Never a suffix, never a segment of the name.
|
||||
# BUILD_COMMIT lets the artifact's self-report be checked against the :<sha>
|
||||
# it was published under (rule 145).
|
||||
#
|
||||
# Each defaults to empty rather than to a placeholder, EXCEPT the name: a
|
||||
# local build genuinely has no ordering key or channel, and the endpoint says
|
||||
# so by omitting them. Inventing values would make a local image claim a
|
||||
# position in an update order it is not part of.
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG BUILD_KEY=
|
||||
ARG BUILD_CHANNEL=
|
||||
ARG BUILD_COMMIT=
|
||||
ENV APP_VERSION=$BUILD_VERSION
|
||||
ENV APP_BUILD_KEY=$BUILD_KEY
|
||||
ENV APP_CHANNEL=$BUILD_CHANNEL
|
||||
ENV APP_COMMIT=$BUILD_COMMIT
|
||||
|
||||
EXPOSE 5000
|
||||
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: build up down logs health migrate lint typecheck test fmt
|
||||
.PHONY: build up down logs health migrate lint typecheck test fmt mint-plugin
|
||||
|
||||
# --- Docker ---
|
||||
|
||||
@@ -36,3 +36,12 @@ test:
|
||||
|
||||
# Run all checks in one shot (mirrors what CI does)
|
||||
check: lint typecheck test
|
||||
|
||||
# --- Plugin ---
|
||||
|
||||
# Run this after changing anything under plugin/ or .claude-plugin/, BEFORE
|
||||
# committing. The plugin ships straight from git with no build step, so its
|
||||
# version is minted here rather than stamped by CI; the lane fails if you
|
||||
# forget, but this is what makes remembering cheap.
|
||||
mint-plugin:
|
||||
python3 scripts/mint_plugin_version.py
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""code_shape_consumers — the CSS consumer map (milestone 302, note 2917)
|
||||
|
||||
Revision ID: 0086
|
||||
Revises: 0085
|
||||
Create Date: 2026-08-23
|
||||
|
||||
CSS is watched by name, by recipe, by token and by WHAT USES IT. This table
|
||||
holds the fourth: CSS shape → the file whose markup names its class, with how
|
||||
many times. Mechanical and recomputed by every coverage sync from the repo
|
||||
archive; the analogue of code_shape_uses for styling. Cascades with the shape.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0086"
|
||||
down_revision = "0085"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"code_shape_consumers",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("shape_id", sa.Integer(), sa.ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("path", sa.Text(), nullable=False),
|
||||
sa.Column("count", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("basis", sa.Text(), nullable=False, server_default="template"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.UniqueConstraint("shape_id", "path", name="uq_code_shape_consumers_shape_path"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("code_shape_consumers")
|
||||
@@ -0,0 +1,109 @@
|
||||
"""canonical_systems — the global area vocabulary, promoted from a constant
|
||||
to a table (milestone 307 step 1, decision note 3026)
|
||||
|
||||
Revision ID: 0087
|
||||
Revises: 0086
|
||||
Create Date: 2026-08-26
|
||||
|
||||
The eight standard area names already existed as `STANDARD_SYSTEMS`, a tuple in
|
||||
services/systems.py that milestone 297 seeds into a project at inception. A
|
||||
constant cannot be referenced: a rule that applies across projects has nothing
|
||||
to point at, because `systems.project_id` is NOT NULL and a family rule cannot
|
||||
be chained to one project's row. This makes the vocabulary a table so it can be
|
||||
a foreign key, and adds the nullable `systems.canonical_id` that maps a
|
||||
project's local System onto it.
|
||||
|
||||
Deliberately no `user_id`: the catalog is GLOBAL so a shared project inherits
|
||||
the vocabulary rather than re-earning it. `record_systems` is untouched — it
|
||||
joins note_id/system_id and never sees this table, so no association data
|
||||
moves, and no System's own `name` is rewritten.
|
||||
|
||||
The seed rows are written here verbatim rather than imported from the service:
|
||||
a migration is a historical record and must keep running unchanged after the
|
||||
service's list moves on.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0087"
|
||||
down_revision = "0086"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
# (name, slug, description) — the milestone-297 vocabulary, with the slug the
|
||||
# service computes (canonical_slug: lowercase, "&" -> "and", non-alphanumerics
|
||||
# collapsed to "-"). Charters stay generic on purpose: a project refines its
|
||||
# own System's description, never this one. Nothing here names an app, a repo,
|
||||
# a vendor or a house convention — the catalog ships to every install (rule 115).
|
||||
_SEED = (
|
||||
("CI & Release", "ci-and-release",
|
||||
"How the project is verified and shipped: pipelines, runners, image/artifact builds, release tagging and rollback."),
|
||||
("Auth & Access", "auth-and-access",
|
||||
"Who may do what: identity, sessions/tokens, permissions and the scoping of every read and write to the right users."),
|
||||
("Data Model & Storage", "data-model-and-storage",
|
||||
"What is stored and how it is shaped: the schema, migrations, serialisation and the services that own a table's lifecycle."),
|
||||
("API Surface", "api-surface",
|
||||
"The doors into the capability: HTTP routes, tool/RPC surfaces, request parsing, error envelopes and their contracts."),
|
||||
("UI & Design", "ui-and-design",
|
||||
"What people see and touch: views, components, client state, and the design tokens/recipes they are built from."),
|
||||
("Import & Export", "import-and-export",
|
||||
"Data crossing the boundary: backups, exports, imports, sync with other systems, file formats."),
|
||||
("Background Jobs", "background-jobs",
|
||||
"Work that runs without a request: schedulers, queues, periodic ticks, retention and maintenance."),
|
||||
("Observability", "observability",
|
||||
"How the system reports on itself: logging, metrics, audit trails, health and diagnostics."),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
canonical_systems = op.create_table(
|
||||
"canonical_systems",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("slug", sa.Text(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
|
||||
)
|
||||
# Unique among LIVE rows only, so a soft-deleted entry doesn't block
|
||||
# recreating or restoring the same area (the rules/topics convention).
|
||||
op.create_index(
|
||||
"uq_canonical_systems_slug", "canonical_systems", ["slug"],
|
||||
unique=True, postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
op.bulk_insert(
|
||||
canonical_systems,
|
||||
[
|
||||
{"name": name, "slug": slug, "description": description, "order_index": index}
|
||||
for index, (name, slug, description) in enumerate(_SEED)
|
||||
],
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"systems",
|
||||
sa.Column("canonical_id", sa.Integer(), nullable=True),
|
||||
)
|
||||
# SET NULL, not CASCADE: retiring a catalog entry must never delete a
|
||||
# project's System along with it.
|
||||
op.create_foreign_key(
|
||||
"fk_systems_canonical_id", "systems", "canonical_systems",
|
||||
["canonical_id"], ["id"], ondelete="SET NULL",
|
||||
)
|
||||
op.create_index("ix_systems_canonical_id", "systems", ["canonical_id"])
|
||||
|
||||
# Existing Systems are left UNMAPPED on purpose. An exact-slug match would
|
||||
# be safe, but a near miss ("CI & runners" vs "CI & Release") is a judgment
|
||||
# call — those go through the propose/confirm path so a human approves each
|
||||
# one, rather than being decided by a migration nobody reviews.
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_systems_canonical_id", table_name="systems")
|
||||
op.drop_constraint("fk_systems_canonical_id", "systems", type_="foreignkey")
|
||||
op.drop_column("systems", "canonical_id")
|
||||
op.drop_index("uq_canonical_systems_slug", table_name="canonical_systems")
|
||||
op.drop_table("canonical_systems")
|
||||
@@ -0,0 +1,105 @@
|
||||
"""rules gain a trigger, a tier, canon tags and typed edges (milestone 307
|
||||
step 3, decision note 3026)
|
||||
|
||||
Revision ID: 0088
|
||||
Revises: 0087
|
||||
Create Date: 2026-08-26
|
||||
|
||||
A rule could not say WHEN it applies, WHICH area it is about, or WHAT other
|
||||
rule it belongs with. All three were being written as prose instead — a
|
||||
project's System description restating rule text, a rule's `why` naming the
|
||||
note that caused it, and two halves of one shape merged into a single row
|
||||
because either could surface without the other.
|
||||
|
||||
Four additions, each replacing something that was already being said in words:
|
||||
|
||||
- `when_to_apply` — the trigger. Nullable HERE and required at the service
|
||||
layer, because existing rules have none and a migration cannot invent one.
|
||||
- `tier` — `always_on` (preloaded, as everything is today) or `conditional`
|
||||
(reachable, surfaced when its trigger fires). Defaults to `always_on`, so
|
||||
this migration changes NOTHING about which rules bind: an install upgrades
|
||||
and every rule keeps arriving exactly as it did.
|
||||
- `arose_from_id` — the record that caused the rule, the edge notes and tasks
|
||||
already have.
|
||||
- `rule_systems` / `rule_relations` — the canon tag and the typed edges.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0088"
|
||||
down_revision = "0087"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Kept in one place so upgrade and the CHECK agree by construction (rule 36:
|
||||
# a whitelisted value means DROP + ADD CONSTRAINT in the same migration —
|
||||
# there is no prior constraint here, so the pair is created together).
|
||||
_TIERS = ("always_on", "conditional")
|
||||
_RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
|
||||
|
||||
|
||||
def _in_list(column: str, values: tuple[str, ...]) -> str:
|
||||
return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("rules", sa.Column("when_to_apply", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"rules",
|
||||
sa.Column("tier", sa.Text(), nullable=False, server_default="always_on"),
|
||||
)
|
||||
op.create_check_constraint("ck_rules_tier", "rules", _in_list("tier", _TIERS))
|
||||
|
||||
# SET NULL, not CASCADE: the record that prompted a rule can be trashed
|
||||
# without taking the rule with it — provenance is a claim about history,
|
||||
# and losing the source does not repeal the rule.
|
||||
op.add_column("rules", sa.Column("arose_from_id", sa.BigInteger(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_rules_arose_from_id", "rules", "notes",
|
||||
["arose_from_id"], ["id"], ondelete="SET NULL",
|
||||
)
|
||||
|
||||
# Which global AREA a rule is about. Points at the canonical catalog, never
|
||||
# at a project's `systems` row — a rule that spans projects cannot be
|
||||
# chained to one project's vocabulary (0087).
|
||||
op.create_table(
|
||||
"rule_systems",
|
||||
sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("canonical_id", sa.Integer(), sa.ForeignKey("canonical_systems.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
op.create_index("ix_rule_systems_canonical_id", "rule_systems", ["canonical_id"])
|
||||
|
||||
# Typed edges between rules. Each kind exists because its absence forced a
|
||||
# workaround: co_surfaces (merging two rules into one row), overrides (a
|
||||
# stricter project rule written as a duplicate), elaborates (a local
|
||||
# addendum sitting beside its parent with nothing to say it is one).
|
||||
op.create_table(
|
||||
"rule_relations",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column("from_rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("to_rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("kind", sa.Text(), nullable=False),
|
||||
sa.Column("note", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
sa.CheckConstraint(_in_list("kind", _RELATION_KINDS), name="ck_rule_relations_kind"),
|
||||
# A rule cannot relate to itself, and one pair carries a given kind
|
||||
# once — a second row would surface the same rule twice.
|
||||
sa.CheckConstraint("from_rule_id <> to_rule_id", name="ck_rule_relations_not_self"),
|
||||
sa.UniqueConstraint("from_rule_id", "to_rule_id", "kind", name="uq_rule_relations_edge"),
|
||||
)
|
||||
op.create_index("ix_rule_relations_from", "rule_relations", ["from_rule_id"])
|
||||
op.create_index("ix_rule_relations_to", "rule_relations", ["to_rule_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_rule_relations_to", table_name="rule_relations")
|
||||
op.drop_index("ix_rule_relations_from", table_name="rule_relations")
|
||||
op.drop_table("rule_relations")
|
||||
op.drop_index("ix_rule_systems_canonical_id", table_name="rule_systems")
|
||||
op.drop_table("rule_systems")
|
||||
op.drop_constraint("fk_rules_arose_from_id", "rules", type_="foreignkey")
|
||||
op.drop_column("rules", "arose_from_id")
|
||||
op.drop_constraint("ck_rules_tier", "rules", type_="check")
|
||||
op.drop_column("rules", "tier")
|
||||
op.drop_column("rules", "when_to_apply")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""rule_embeddings — rules become findable by meaning (milestone 307 step 4,
|
||||
decision note 3026)
|
||||
|
||||
Revision ID: 0089
|
||||
Revises: 0088
|
||||
Create Date: 2026-08-26
|
||||
|
||||
Rules were the only major record type with no vector, so `search` could never
|
||||
return one and a rule could only ever arrive by being preloaded. That single
|
||||
fact is what made every rule compete for the same always-on budget.
|
||||
|
||||
A sibling table rather than a generalisation of note_embeddings: the row could
|
||||
have been made polymorphic, but the SEARCH could not — semantic_search_notes is
|
||||
Note-specific scoping end to end, and a rule shares none of it. See the model
|
||||
docstring for the full reasoning.
|
||||
|
||||
The vectors are DERIVED data. Nothing is backfilled here: the startup backfill
|
||||
regenerates them, which is also how a chunker-version bump is handled.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0089"
|
||||
down_revision = "0088"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# Matches note_embeddings — bge-small-en-v1.5, 384-dim unit-normalized.
|
||||
_EMBEDDING_DIM = 384
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"rule_embeddings",
|
||||
sa.Column("rule_id", sa.BigInteger(), sa.ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("chunk_index", sa.Integer(), primary_key=True),
|
||||
sa.Column("chunk_text", sa.Text(), nullable=False),
|
||||
sa.Column("chunker_version", sa.Integer(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||
)
|
||||
# The vector column is added by raw DDL for the same reason 0067 did it:
|
||||
# the type comes from the pgvector extension, not from SQLAlchemy's
|
||||
# type system.
|
||||
op.execute(f"ALTER TABLE rule_embeddings ADD COLUMN embedding vector({_EMBEDDING_DIM}) NOT NULL")
|
||||
# HNSW for cosine distance — matches Vector.cosine_distance (`<=>`), so the
|
||||
# search is an indexed ORDER BY ... LIMIT k rather than a full scan.
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX ix_rule_embeddings_embedding_hnsw
|
||||
ON rule_embeddings
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_rule_embeddings_embedding_hnsw")
|
||||
op.drop_table("rule_embeddings")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""a rule can carry its own check — verify_with, expires_when, verified_at
|
||||
(milestone 312 step 1)
|
||||
|
||||
Revision ID: 0090
|
||||
Revises: 0089
|
||||
Create Date: 2026-08-27
|
||||
|
||||
A rulebook holds two kinds of row in one table. A NORM is a decision: it has
|
||||
no truth value, and it changes only when its author changes it — which they
|
||||
know they did. A CONSTRAINT is a fact about someone else's software: a
|
||||
runner's shell, a bot's config, a tool that exists. Nobody is present when
|
||||
that goes false.
|
||||
|
||||
Milestone 307's rulebook audit found nine stale sites. Every one was a
|
||||
constraint; not one norm had rotted. One of them had been telling every
|
||||
session to skip database-backed tests for weeks while the integration lane
|
||||
sat green in the workflow.
|
||||
|
||||
Three nullable columns, so a rule can say how to check itself:
|
||||
|
||||
- `verify_with` — how to tell whether this is still true. A command, a path,
|
||||
a URL, a query. Prose is allowed; something runnable is better.
|
||||
- `expires_when` — the STATE under which it stops being true. Deliberately
|
||||
not a date: constraints do not expire on a schedule, they expire when the
|
||||
world underneath them moves.
|
||||
- `verified_at` — when the check last passed. NULL means never checked, and
|
||||
sorts FIRST in the sweep: unexamined outranks examined-long-ago.
|
||||
|
||||
All three nullable and all three optional, because most rules should set
|
||||
none of them. A null `verify_with` is not an omission — it is the honest
|
||||
marker of "this one is a decision, and there is nothing to go and check."
|
||||
That signal only works if the field stays empty wherever it belongs empty.
|
||||
|
||||
No CHECK constraint is involved, so rule 36 does not apply here. Nothing is
|
||||
backfilled: a migration cannot invent a check any more than 0088 could
|
||||
invent a trigger.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0090"
|
||||
down_revision = "0089"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("rules", sa.Column("verify_with", sa.Text(), nullable=True))
|
||||
op.add_column("rules", sa.Column("expires_when", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"rules",
|
||||
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# No index on (verify_with, verified_at). The sweep this exists for reads
|
||||
# an operator's whole rulebook — hundreds of rows, not millions — and runs
|
||||
# when a human asks for it, never on a request path. An index here would
|
||||
# be maintained on every rule write to serve a query that a sequential
|
||||
# scan answers instantly.
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("rules", "verified_at")
|
||||
op.drop_column("rules", "expires_when")
|
||||
op.drop_column("rules", "verify_with")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""task_kind gains 'spike' — the investigation, not the change
|
||||
(milestone 312 step 5)
|
||||
|
||||
Revision ID: 0091
|
||||
Revises: 0090
|
||||
Create Date: 2026-08-27
|
||||
|
||||
A spike is a task shape the others cannot hold. `work` ships a change;
|
||||
`issue` fixes something broken. A spike is time-boxed and its output is
|
||||
KNOWLEDGE — it succeeds by producing an answer, and nothing ships at the
|
||||
end of it. "Find out whether the runner can be given a bash shell" is not
|
||||
work, and filing it as work makes a finished investigation look like an
|
||||
abandoned change.
|
||||
|
||||
It is the record a failed check asks for. Milestone 312 gave rules a
|
||||
`verify_with`; when one of those fails, the rule is wrong and the next move
|
||||
is often to go and find out what replaced it. `notes.arose_from_id` already
|
||||
exists (0065), so that constraint -> spike link needs no further schema.
|
||||
|
||||
Rule 36: `task_kind` is gated by a CHECK whitelist, so the value and the
|
||||
widened constraint land in the SAME migration — DROP then ADD, exactly as
|
||||
0065 did when it introduced 'issue'. Adding the value and constraining it
|
||||
later leaves a window where the database accepts anything.
|
||||
|
||||
'plan' stays in the list though it is retired (plans are milestones since
|
||||
0066): historical plan-tasks still carry it, and dropping it from the
|
||||
whitelist would make old rows unwritable.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "0091"
|
||||
down_revision = "0090"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# One tuple so the upgrade and the downgrade cannot disagree about what the
|
||||
# list was on either side of this migration.
|
||||
_KINDS_AFTER = ("work", "plan", "issue", "spike")
|
||||
_KINDS_BEFORE = ("work", "plan", "issue")
|
||||
|
||||
|
||||
# Restated rather than imported from 0088, which has the same helper. A
|
||||
# migration is a snapshot: it must keep working when the code around it has
|
||||
# moved on, so it never imports from live modules or from its siblings. Six
|
||||
# duplicated lines are the price of that, and the cheap half of the bargain.
|
||||
def _in_list(values: tuple[str, ...]) -> str:
|
||||
return "task_kind IN (" + ", ".join(f"'{v}'" for v in values) + ")"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
|
||||
op.create_check_constraint(
|
||||
"notes_task_kind_check", "notes", _in_list(_KINDS_AFTER),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Any row already filed as a spike would violate the narrowed constraint,
|
||||
# so they are demoted to 'work' first. Lossy and deliberately so: the
|
||||
# alternative is a downgrade that fails on real data, which is worse than
|
||||
# a downgrade that says what it did.
|
||||
op.execute("UPDATE notes SET task_kind = 'work' WHERE task_kind = 'spike'")
|
||||
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
|
||||
op.create_check_constraint(
|
||||
"notes_task_kind_check", "notes", _in_list(_KINDS_BEFORE),
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""a note can carry its own check — verify_with, expires_when, verified_at
|
||||
(milestone 317 step 1)
|
||||
|
||||
Revision ID: 0092
|
||||
Revises: 0091
|
||||
Create Date: 2026-08-28
|
||||
|
||||
The sibling of 0090, which gave rules the same three columns. Same
|
||||
distinction, one table over:
|
||||
|
||||
A NORM is a decision — no truth value, and it changes only when its author
|
||||
changes it, which they know they did. A CONSTRAINT is a fact about someone
|
||||
else's software, and nobody is present when it goes false.
|
||||
|
||||
Notes hold far more constraints than rules do, and hold them for longer. A
|
||||
cross-project reference note asserting what a signing service does on a
|
||||
duplicate upload, or how a forge numbers its CI runs, is believed by every
|
||||
project that reads it, and there is nothing in the record that says when
|
||||
anyone last looked. `note_supersessions` only fires once a human has read
|
||||
the note, disagreed, and written the correction — which is the case where
|
||||
the note was already believed.
|
||||
|
||||
Three nullable columns:
|
||||
|
||||
- `verify_with` — how to tell whether this is still true. A command, a path,
|
||||
a URL, a query. Prose is allowed; something runnable is better.
|
||||
- `expires_when` — the STATE under which it stops being true. Deliberately
|
||||
not a date: constraints do not expire on a schedule, they expire when the
|
||||
world underneath them moves.
|
||||
- `verified_at` — when the check last passed. NULL means never checked, and
|
||||
sorts FIRST in the sweep: unexamined outranks examined-long-ago.
|
||||
|
||||
WHICH ROWS THESE ARE FOR. `notes` is one table holding notes, tasks,
|
||||
snippets and processes, so these columns land on all of them. Only non-task,
|
||||
non-snippet records are OFFERED them (milestone 317 decisions 1 and 2, gated
|
||||
at the service in step 2): a task's decay is its status, and a snippet
|
||||
already carries a richer, location-aware verdict in `data.verification`. The
|
||||
columns exist on the other rows and stay null there; a gate that lives in
|
||||
the schema would have meant a partial index or a CHECK across three columns
|
||||
to express something the write path can say in two lines.
|
||||
|
||||
All three optional, because most notes should set none of them — the whole
|
||||
value of the sweep is that its output is short. A null `verify_with` is not
|
||||
an omission; it is the honest marker of "this one is a decision, and there
|
||||
is nothing to go and check."
|
||||
|
||||
No CHECK constraint is involved, so rule 36 does not apply. Nothing is
|
||||
backfilled: a migration cannot invent a check.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0092"
|
||||
down_revision = "0091"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("notes", sa.Column("verify_with", sa.Text(), nullable=True))
|
||||
op.add_column("notes", sa.Column("expires_when", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"notes",
|
||||
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
# No index, for 0090's reason — the sweep runs when a human asks, never on
|
||||
# a request path — but the margin is thinner here and worth naming. `rules`
|
||||
# is hundreds of rows; `notes` is thousands and grows with every session.
|
||||
#
|
||||
# Still a sequential scan's job at this size, and an index on
|
||||
# (verified_at) filtered to `verify_with IS NOT NULL` would be maintained
|
||||
# on every note write to serve one operator-initiated query. If step 3's
|
||||
# live acceptance measures otherwise, add it there against a real plan
|
||||
# rather than guessing here.
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("notes", "verified_at")
|
||||
op.drop_column("notes", "expires_when")
|
||||
op.drop_column("notes", "verify_with")
|
||||
@@ -0,0 +1,83 @@
|
||||
"""rules gain an edit history — rule_versions (milestone 323 step 1)
|
||||
|
||||
Revision ID: 0093
|
||||
Revises: 0092
|
||||
Create Date: 2026-08-29
|
||||
|
||||
The sibling `note_versions` has had for a long time. A note's every meaningful
|
||||
edit is snapshotted, and the design-system note calls that history "the
|
||||
changelog". A RULE — which binds behaviour on every session that loads it —
|
||||
had nothing: an edit destroyed what it used to say, with no record anywhere.
|
||||
|
||||
Rescoping rule 79 on 2026-08-29 is what surfaced it. The superseded statement
|
||||
had to be hand-copied into a task log to survive the edit (#3237), which is
|
||||
not a process, it is a person remembering. The more consequential record had
|
||||
the weaker protection.
|
||||
|
||||
Three things are deliberately NOT copied from note_versions, and each is a
|
||||
guard that exists there for a reason that does not hold here:
|
||||
|
||||
- **No pruning, and no MAX_VERSIONS.** That cap defends against note autosave
|
||||
filling every slot. Rules have no autosave; every edit is a deliberate
|
||||
update_rule. A rule is edited a handful of times in its life, and capping
|
||||
invites losing the one edit somebody needed.
|
||||
- **No pin columns.** `pin_kind`/`pin_label` exist so a note's version can
|
||||
survive that pruning. With nothing pruning, a pin protects a row that was
|
||||
never at risk.
|
||||
- **No minimum interval.** 300 seconds between snapshots is also an autosave
|
||||
defence; here it would only ever discard a second deliberate edit.
|
||||
|
||||
`user_id` is the ACTOR rather than the owner, and is SET NULL rather than
|
||||
CASCADE: deleting a user must not erase the history of the rules they edited.
|
||||
The edit still happened and the rule still binds because of it.
|
||||
|
||||
No CHECK constraint, so rule 36 does not apply. Nothing is backfilled — a
|
||||
migration cannot invent the text a rule used to have, and inventing "the
|
||||
current text, as of now" would be worse than an empty history, because it
|
||||
would look like a record of an edit that never occurred.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0093"
|
||||
down_revision = "0092"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"rule_versions",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column(
|
||||
"rule_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("rules.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("title", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("statement", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("why", sa.Text(), nullable=True),
|
||||
sa.Column("how_to_apply", sa.Text(), nullable=True),
|
||||
sa.Column("when_to_apply", sa.Text(), nullable=True),
|
||||
sa.Column("tier", sa.Text(), nullable=True),
|
||||
sa.Column("verify_with", sa.Text(), nullable=True),
|
||||
sa.Column("expires_when", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
# The only query this table serves is "the history of THIS rule, newest
|
||||
# first" — unlike 0092's columns, which are read by an operator-initiated
|
||||
# sweep over the whole set. Every read here is keyed on rule_id, so the
|
||||
# index earns its write cost immediately rather than on a hunch.
|
||||
op.create_index("ix_rule_versions_rule_id", "rule_versions", ["rule_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_rule_versions_rule_id", table_name="rule_versions")
|
||||
op.drop_table("rule_versions")
|
||||
@@ -0,0 +1,86 @@
|
||||
"""add rule_usage_events — was a surfaced rule ever read? (milestone 333 step 1)
|
||||
|
||||
Revision ID: 0094
|
||||
Revises: 0093
|
||||
Create Date: 2026-09-02
|
||||
|
||||
The sibling `note_usage_events` has had since 0071, and the third rule-side
|
||||
table to arrive after `rule_embeddings` and `rule_versions` — each one added
|
||||
because the rule side kept inheriting machinery built for notes and getting
|
||||
the weaker version of it.
|
||||
|
||||
WHAT IT MEASURES. The write-path standing-rule arm is the only retrieval
|
||||
surface in Scribe whose usefulness cannot be observed, and — not coincidentally
|
||||
— the only one that has never declined to fire. Over 30 days it took 296 calls,
|
||||
returned something on every one, and cleared its threshold 100% of the time,
|
||||
while every other surface declines most of the time (#3311). That is either a
|
||||
perfectly tuned surface or a bar it cannot fail to clear, and `retrieval_logs`
|
||||
cannot tell them apart: it records what the ranker scored, never whether the
|
||||
hint was any use.
|
||||
|
||||
WHY NOT A rule_id COLUMN ON note_usage_events. The row shares no note-specific
|
||||
fields and the aggregate readout is the same shape, which is the strongest case
|
||||
for sharing that note #3163 admits. What decides against it is identity at
|
||||
RESTORE: `note_usage_events`'s importer maps `note_id` through `note_id_map`
|
||||
and drops what does not resolve. A rule id parked in that column would come
|
||||
back from a backup silently reattached to whatever note took that number —
|
||||
telemetry not merely lost but wrong, and wrong in a way nothing downstream
|
||||
could detect. `rule_versions` made the same call for the same reason.
|
||||
|
||||
FK-free on `rule_id` and `user_id`, matching note_usage_events, retrieval_logs
|
||||
and app_logs — and deliberately unlike `rule_versions`, which does carry FKs.
|
||||
The difference is what the row is for: a version belongs to a rule's history
|
||||
and dies with it; telemetry outlives the row it describes. Deleting a rule must
|
||||
not erase the evidence that it was surfaced forty times and opened never, since
|
||||
that evidence is exactly the case for having deleted it.
|
||||
|
||||
No CHECK on `event`, matching the note twin. Rule 36 governs adding a value to
|
||||
a column that is already gated; it does not require gating one that never was,
|
||||
and a two-member enum whose members are written by two functions in one module
|
||||
is not where that discipline earns its cost.
|
||||
|
||||
Downgrade drops the table outright. The data is purely observational — nothing
|
||||
reads it for correctness, so losing it costs history and no behaviour.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0094"
|
||||
down_revision = "0093"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"rule_usage_events",
|
||||
# BigInteger throughout where the note twin uses Integer: rules.id is
|
||||
# BigInteger, so rule_id must be, and a high-churn append-only table is
|
||||
# a poor place to discover an id ceiling.
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("rule_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("event", sa.Text(), nullable=False),
|
||||
sa.Column("source", sa.Text(), nullable=False),
|
||||
)
|
||||
# Every readout is "these rule ids, split by event", so the composite is the
|
||||
# one that actually gets used; the others serve pruning and per-user views.
|
||||
op.create_index(
|
||||
"ix_rule_usage_rule_event", "rule_usage_events", ["rule_id", "event"]
|
||||
)
|
||||
op.create_index("ix_rule_usage_created_at", "rule_usage_events", ["created_at"])
|
||||
op.create_index("ix_rule_usage_user_id", "rule_usage_events", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_rule_usage_user_id", table_name="rule_usage_events")
|
||||
op.drop_index("ix_rule_usage_created_at", table_name="rule_usage_events")
|
||||
op.drop_index("ix_rule_usage_rule_event", table_name="rule_usage_events")
|
||||
op.drop_table("rule_usage_events")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""add retrieval_logs.suppressed_count — tell a ranker decline from a repeat (#3497)
|
||||
|
||||
Revision ID: 0095
|
||||
Revises: 0094
|
||||
Create Date: 2026-09-03
|
||||
|
||||
`result_count == 0` has always meant "this surface said nothing", which is the
|
||||
right number for "was the hint any use" and the wrong one for tuning a
|
||||
threshold. It folds together two unrelated events:
|
||||
|
||||
- the ranker found nothing above the bar — the ONLY evidence a threshold is
|
||||
set too high; and
|
||||
- the ranker found something the session had already been shown — a decline
|
||||
that says nothing whatever about the bar.
|
||||
|
||||
The rule arms filter in Python after the search, so they can count the second
|
||||
kind exactly. The note arms pass `exclude_ids` INTO semantic_search_notes, so
|
||||
the dropped rows never come back and there is nothing to count.
|
||||
|
||||
NULLABLE, AND THE NULL IS THE POINT. A surface that does not measure
|
||||
suppression stores NULL, not 0, and the readout renders it as "not measured"
|
||||
rather than "none". Defaulting to 0 would make an unmeasured surface look like
|
||||
a perfectly clean one — the exact substitution of an artifact for a
|
||||
measurement that #3311 made and that #3497 exists to correct. Doing it again,
|
||||
in the migration that fixes it, would be its own small joke.
|
||||
|
||||
No backfill for the same reason: existing rows genuinely do not know, and
|
||||
saying so is the honest state. `retrieval_logs` is not restored from backup,
|
||||
so no importer changes.
|
||||
|
||||
Downgrade drops the column. Purely observational — nothing reads it for
|
||||
correctness.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0095"
|
||||
down_revision = "0094"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"retrieval_logs",
|
||||
sa.Column("suppressed_count", sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("retrieval_logs", "suppressed_count")
|
||||
@@ -43,8 +43,10 @@ client straight to the URL with a Bearer token.
|
||||
|
||||
Authenticate with an API key generated from **Settings → API Keys** (see above),
|
||||
sent as `Authorization: Bearer fmcp_<key>`. A `read`-scoped key may call only the
|
||||
read tools (`get_*`, `list_*`, `search`, `enter_project`); any write/delete tool
|
||||
is rejected with `403`. A `write`-scoped key may call everything.
|
||||
read tools (`get_*`, `list_*`, `search`, `enter_project`, `retrieval_telemetry`);
|
||||
any write/delete tool is rejected with `403`. The allow-list is explicit rather
|
||||
than derived from the name — see `_READ_ONLY_TOOLS`, which is why the two reads
|
||||
without a read-shaped name are spelled out here. A `write`-scoped key may call everything.
|
||||
|
||||
### Claude Code (Project-scoped)
|
||||
|
||||
@@ -85,7 +87,7 @@ table here. The tools are grouped by family:
|
||||
| Notes | `create_note`, `get_note`, `update_note`, `delete_note`, `list_notes` | Free-form knowledge |
|
||||
| Tasks | `create_task`, `update_task`, `add_task_log`, `start_planning` | Actionable work + plans |
|
||||
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
|
||||
| Search / Recall | `search`, `get_recent`, `list_tags` | Semantic + structured recall |
|
||||
| Search / Recall | `search`, `get_recent`, `list_tags`, `retrieval_telemetry` | Semantic + structured recall, and the readout its thresholds are tuned from |
|
||||
| Systems | `create_system`, `list_systems`, `list_system_records` | Reusable per-project subsystems/areas |
|
||||
| Rulebooks | `list_always_on_rules`, `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules |
|
||||
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
|
||||
|
||||
@@ -172,6 +172,8 @@ endpoint at `/mcp`, not these REST routes.
|
||||
| GET | `/api/plugin/context` | SessionStart context payload (rules + active-project) |
|
||||
| GET | `/api/plugin/retrieve` | Title-first knowledge-injection candidates |
|
||||
| GET | `/api/plugin/processes` | Stored Processes for skill-stub sync |
|
||||
| GET | `/api/plugin/prior-art` | Write-path hint for the plugin hooks (params: `path`, `code`, `repo`, `shapes`, `exclude_ids`, `exclude_sync_ids`, `exclude_derive`); returns `context`, `note_ids`, `sync_note_ids`, `stamped`, `divergence`, `derive`, `derive_keys` |
|
||||
| GET / POST | `/api/projects/<id>/coverage`, `…/coverage/refresh` | Shape-ledger accounting (`pattern_coverage` line, counts, `derive_groups` — css groups carry `consumers`, `derive_new`, `unused_css`, `divergence`, `recheck`) |
|
||||
| GET / PUT | `/api/plugin/marketplace-url` | Read / set the plugin marketplace URL |
|
||||
|
||||
## Dashboard, Export, Trash, Users
|
||||
|
||||
+19
-6
@@ -7,12 +7,20 @@ import { useTheme } from "@/composables/useTheme";
|
||||
import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useSettingsStore } from "@/stores/settings";
|
||||
import { apiGet, apiPut } from "@/api/client";
|
||||
import { apiPut } from "@/api/client";
|
||||
import { fetchVersion } from "@/api/version";
|
||||
|
||||
useTheme();
|
||||
|
||||
const router = useRouter();
|
||||
const appVersion = ref("dev");
|
||||
// THREE states, not two (#3127 checklist 12). `null` is "not answered yet" and
|
||||
// renders nothing; a string renders; `appVersionFailed` renders its own thing.
|
||||
// This used to default to the literal "dev" and swallow the error, which meant
|
||||
// an instance that could not answer was indistinguishable from a local build
|
||||
// that genuinely reports "dev" — a blank standing in for `unknown`, in the one
|
||||
// readout whose whole job is to say what is running.
|
||||
const appVersion = ref<string | null>(null);
|
||||
const appVersionFailed = ref(false);
|
||||
const authStore = useAuthStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
|
||||
@@ -119,10 +127,12 @@ onMounted(async () => {
|
||||
startAppServices();
|
||||
}
|
||||
try {
|
||||
const data = await apiGet<{ version: string }>("/api/version");
|
||||
appVersion.value = data.version;
|
||||
appVersion.value = (await fetchVersion()).version;
|
||||
} catch {
|
||||
// silent — version display is non-critical
|
||||
// Not silent any more: the footer says it could not find out, rather than
|
||||
// showing a version it never received. The full readout (version, channel,
|
||||
// commit, build) lives in Settings → Config.
|
||||
appVersionFailed.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -151,7 +161,10 @@ onUnmounted(() => {
|
||||
<div id="main-content" class="app-content">
|
||||
<router-view />
|
||||
</div>
|
||||
<footer class="app-footer">v{{ appVersion }}</footer>
|
||||
<footer class="app-footer">
|
||||
<span v-if="appVersion">v{{ appVersion }}</span>
|
||||
<span v-else-if="appVersionFailed">version unknown</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard shortcuts overlay -->
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Canonical systems — the GLOBAL area vocabulary every project's Systems can
|
||||
* map onto (milestone 307).
|
||||
*
|
||||
* The mapping is an ASSOCIATION, never a rename: a project's System keeps the
|
||||
* name the project gave it, and `canonical_id` only records which shared area
|
||||
* it is an instance of. An unmapped System is fully usable — the catalog is a
|
||||
* convergence aid, not a gate.
|
||||
*/
|
||||
import { apiGet, apiPost, apiPatch, apiPut } from "@/api/client";
|
||||
|
||||
export interface CanonicalSystem {
|
||||
id: number;
|
||||
name: string;
|
||||
/** The match key: lowercase, "&" folded to "and", punctuation collapsed. */
|
||||
slug: string;
|
||||
description: string | null;
|
||||
order_index: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A suggested mapping. `basis` is the whole point of showing it:
|
||||
* - `exact` — the names differ only in spelling. Mechanical.
|
||||
* - `overlap` — they share a meaningful word. A judgment call the reviewer is
|
||||
* making, and it must never be presented as if it were the first.
|
||||
*/
|
||||
export interface CanonicalMatch {
|
||||
id: number;
|
||||
name: string;
|
||||
basis: "exact" | "overlap";
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface MappingProposal {
|
||||
system_id: number;
|
||||
system_name: string;
|
||||
canonical_id: number;
|
||||
canonical_name: string;
|
||||
basis: "exact" | "overlap";
|
||||
score: number;
|
||||
}
|
||||
|
||||
export async function listCanonicalSystems(): Promise<CanonicalSystem[]> {
|
||||
const data = await apiGet<{ canonical_systems: CanonicalSystem[] }>(
|
||||
"/api/canonical-systems",
|
||||
);
|
||||
return data.canonical_systems;
|
||||
}
|
||||
|
||||
/** Admin only — a global list anyone can extend stops being shared. */
|
||||
export async function createCanonicalSystem(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
}): Promise<CanonicalSystem> {
|
||||
return apiPost("/api/canonical-systems", data);
|
||||
}
|
||||
|
||||
export async function updateCanonicalSystem(
|
||||
id: number,
|
||||
data: Partial<{ name: string; description: string; order_index: number }>,
|
||||
): Promise<CanonicalSystem> {
|
||||
return apiPatch(`/api/canonical-systems/${id}`, data);
|
||||
}
|
||||
|
||||
/** Proposals for a project's UNMAPPED Systems. Reads only — nothing applied. */
|
||||
export async function proposeMappings(projectId: number): Promise<MappingProposal[]> {
|
||||
const data = await apiGet<{ proposals: MappingProposal[] }>(
|
||||
`/api/projects/${projectId}/canonical-proposals`,
|
||||
);
|
||||
return data.proposals;
|
||||
}
|
||||
|
||||
/** Apply or clear one mapping. `null` unmaps. */
|
||||
export async function mapSystem(
|
||||
systemId: number,
|
||||
canonicalId: number | null,
|
||||
): Promise<{ id: number; canonical_id: number | null }> {
|
||||
return apiPut(`/api/systems/${systemId}/canonical`, { canonical_id: canonicalId });
|
||||
}
|
||||
+127
-32
@@ -52,41 +52,121 @@ export function apiErrorMessage(e: unknown, fallback: string): string {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path);
|
||||
/**
|
||||
* How long an ordinary JSON call may wait before it is declared failed.
|
||||
*
|
||||
* Rule 156: a wait with no deadline is a bug. `fetch`'s own default is to wait
|
||||
* as long as the browser will, which is not a deadline — it is the absence of
|
||||
* one, and it renders as a spinner that never resolves. There is no state a
|
||||
* surface can show for "pending forever" that is not a lie.
|
||||
*
|
||||
* 30s is chosen to be longer than anything healthy: it has to clear a cold
|
||||
* embedding call and a list view under connection-pool contention (#2384 had
|
||||
* /api/projects fanning 25 concurrent sessions at a 15-connection pool), so
|
||||
* tripping it means something is genuinely wrong rather than merely busy. Slow
|
||||
* BY DESIGN is a different case and passes its own value — see the callers in
|
||||
* SettingsView that do.
|
||||
*/
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** HTTP 408. Not a status any Scribe route returns, so it unambiguously means
|
||||
* "the client gave up" rather than anything the server said. */
|
||||
const CLIENT_TIMEOUT_STATUS = 408;
|
||||
|
||||
/**
|
||||
* How long a STREAM may take to answer with its headers.
|
||||
*
|
||||
* Streams are the one case a wall-clock deadline would break: a long-lived SSE
|
||||
* connection is *supposed* to stay open, and `AbortSignal.timeout` would kill
|
||||
* it mid-flight along with the body. But that does not exempt them from rule
|
||||
* 156 — it relocates the deadline. Two different waits are involved:
|
||||
*
|
||||
* connect — the server answering with headers. CAN fail to answer, so it
|
||||
* carries this deadline, cleared the moment headers arrive.
|
||||
* stream — the body, open indefinitely on purpose. Its failure mode is
|
||||
* going quiet, which a timeout cannot tell from being idle; that
|
||||
* is what reconnection and Last-Event-ID are for, not this.
|
||||
*
|
||||
* Reading the connect as exempt because "the stream is long-lived" is the easy
|
||||
* mistake here, and it leaves an unreachable server looking like a quiet one.
|
||||
*/
|
||||
const STREAM_CONNECT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* A signal that aborts if headers do not arrive in time, plus the `settle` to
|
||||
* call once they do. After `settle()` the returned signal never fires, so the
|
||||
* stream body runs unbounded — which is the intent.
|
||||
*/
|
||||
function connectDeadline(base: AbortSignal): { signal: AbortSignal; settle: () => void } {
|
||||
const gate = new AbortController();
|
||||
const timer = setTimeout(
|
||||
() => gate.abort(new DOMException("stream did not connect in time", "TimeoutError")),
|
||||
STREAM_CONNECT_TIMEOUT_MS,
|
||||
);
|
||||
return {
|
||||
signal: AbortSignal.any([base, gate.signal]),
|
||||
settle: () => clearTimeout(timer),
|
||||
};
|
||||
}
|
||||
|
||||
export interface RequestOpts {
|
||||
/** Override the deadline. Pass one when the call is slow BY DESIGN. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a request is actually made — every verb below goes through
|
||||
* here, so the deadline cannot be forgotten by adding a sixth.
|
||||
*
|
||||
* EXPIRY SURFACES AS AN `ApiError`, which is rule 156's second half: the
|
||||
* failure has to arrive in the shape the caller already handles. A bare
|
||||
* `DOMException: TimeoutError` would reach `apiErrorMessage(e, fallback)` as
|
||||
* an object with no `body`, so every catch site in the app would report its
|
||||
* generic fallback and the timeout would be invisible in the very situation it
|
||||
* exists to expose. Rethrowing as `ApiError` means ~330 existing call sites
|
||||
* report it correctly without being touched.
|
||||
*
|
||||
* Only a TIMEOUT is converted. A deliberate cancellation aborts with
|
||||
* `AbortError` and is left alone — a caller that cancelled its own request
|
||||
* does not want it reported as a server failure.
|
||||
*/
|
||||
async function request<T>(path: string, init: RequestInit, opts?: RequestOpts): Promise<T> {
|
||||
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === "TimeoutError") {
|
||||
throw new ApiError(CLIENT_TIMEOUT_STATUS, {
|
||||
error: `The server did not answer within ${Math.round(timeoutMs / 1000)}s.`,
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return handleResponse<T>(res, path);
|
||||
}
|
||||
|
||||
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return handleResponse<T>(res, path);
|
||||
/** JSON body headers — the three write verbs sent an identical literal each. */
|
||||
const JSON_HEADERS = { "Content-Type": "application/json" };
|
||||
|
||||
export function apiGet<T>(path: string, opts?: RequestOpts): Promise<T> {
|
||||
return request<T>(path, {}, opts);
|
||||
}
|
||||
|
||||
export async function apiPut<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return handleResponse<T>(res, path);
|
||||
export function apiPost<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
||||
return request<T>(path, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
||||
}
|
||||
|
||||
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return handleResponse<T>(res, path);
|
||||
export function apiPut<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
||||
return request<T>(path, { method: "PUT", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
||||
}
|
||||
|
||||
export async function apiDelete(path: string): Promise<void> {
|
||||
const res = await fetch(path, { method: "DELETE" });
|
||||
return handleResponse<void>(res, path);
|
||||
export function apiPatch<T>(path: string, body: unknown, opts?: RequestOpts): Promise<T> {
|
||||
return request<T>(path, { method: "PATCH", headers: JSON_HEADERS, body: JSON.stringify(body) }, opts);
|
||||
}
|
||||
|
||||
export function apiDelete(path: string, opts?: RequestOpts): Promise<void> {
|
||||
return request<void>(path, { method: "DELETE" }, opts);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -221,7 +301,14 @@ export function apiSSEStream(
|
||||
}
|
||||
|
||||
const done = (async () => {
|
||||
const res = await fetch(path, { headers, signal: combinedSignal });
|
||||
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
|
||||
const connect = connectDeadline(combinedSignal);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, { headers, signal: connect.signal });
|
||||
} finally {
|
||||
connect.settle();
|
||||
}
|
||||
if (!res.ok) {
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
@@ -318,11 +405,19 @@ export async function apiStreamPost(
|
||||
body: unknown,
|
||||
onChunk: (data: Record<string, unknown>) => void
|
||||
): Promise<void> {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
// Bounded connect, unbounded stream — see STREAM_CONNECT_TIMEOUT_MS.
|
||||
const connect = connectDeadline(new AbortController().signal);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(body),
|
||||
signal: connect.signal,
|
||||
});
|
||||
} finally {
|
||||
connect.settle();
|
||||
}
|
||||
if (!res.ok) {
|
||||
let errBody: Record<string, unknown> = {};
|
||||
try {
|
||||
|
||||
+209
-14
@@ -1,5 +1,26 @@
|
||||
import type { RecordUsage } from "@/types/usage";
|
||||
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
|
||||
/** How a rule reaches a session (milestone 307). */
|
||||
export type RuleTier = "always_on" | "conditional";
|
||||
|
||||
/**
|
||||
* A typed edge between two rules. Each kind exists because its absence forced
|
||||
* a workaround: merging two rules into one row, writing an override as a
|
||||
* near-copy, or leaving a local addendum with nothing to say it is one.
|
||||
*/
|
||||
export type RuleRelationKind = "co_surfaces" | "overrides" | "elaborates";
|
||||
|
||||
export interface RuleRelation {
|
||||
id: number;
|
||||
kind: RuleRelationKind;
|
||||
/** The rule at the OTHER end. */
|
||||
rule_id: number;
|
||||
direction: "outgoing" | "incoming";
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface Rulebook {
|
||||
id: number;
|
||||
owner_user_id: number;
|
||||
@@ -26,35 +47,76 @@ export interface Rule {
|
||||
project_id: number | null;
|
||||
title: string;
|
||||
statement: string;
|
||||
/** WHEN this rule fires — the trigger, not the instruction. */
|
||||
when_to_apply: string;
|
||||
/**
|
||||
* always_on preloads into every session; conditional is reachable and
|
||||
* surfaced when its trigger fires. A rule with no tier set behaves as
|
||||
* always_on, which is how every rule behaved before this existed.
|
||||
*/
|
||||
tier: RuleTier;
|
||||
why: string;
|
||||
how_to_apply: string;
|
||||
/**
|
||||
* How to check the rule is still true, and the state that ends it. Set
|
||||
* only on a rule that asserts a fact about something outside the
|
||||
* operator's control; empty on a rule that is a decision, which is most
|
||||
* of them. Empty is meaningful, not missing.
|
||||
*/
|
||||
verify_with: string;
|
||||
expires_when: string;
|
||||
/** When the check last passed. Null means never checked. */
|
||||
verified_at: string | null;
|
||||
/** The note or task that caused this rule, if one was recorded. */
|
||||
arose_from_id: number | null;
|
||||
order_index: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
/** Present only when the rule has them (the server omits empty keys). */
|
||||
systems?: { id: number; name: string }[];
|
||||
relations?: RuleRelation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule as a LIST ROW — services.rulebooks.rule_brief's output. Carries the
|
||||
* age deliberately: a rule written before the capability it duplicates is
|
||||
* otherwise indistinguishable, at a glance, from one still doing work.
|
||||
*/
|
||||
export interface RuleHeader {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
topic_id: number | null;
|
||||
tier: RuleTier;
|
||||
/** A date (YYYY-MM-DD), not a timestamp. */
|
||||
updated_at: string | null;
|
||||
when_to_apply?: string;
|
||||
arose_from_id?: number;
|
||||
/**
|
||||
* Present ONLY on a rule that carries a check — the presence of the key
|
||||
* is itself the signal that this rule asserts a fact that can go false.
|
||||
* A date (YYYY-MM-DD), or the literal "never".
|
||||
*/
|
||||
last_verified?: string;
|
||||
/**
|
||||
* Surfaced-vs-opened counts from `rule_usage_events` (milestone 333).
|
||||
* Zero-filled by the list route, so a rule predating the table reads as
|
||||
* "never surfaced" rather than as a missing field — which for a while is
|
||||
* every rule on every install.
|
||||
*/
|
||||
usage?: RecordUsage;
|
||||
}
|
||||
|
||||
export interface ApplicableRules {
|
||||
rules: {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
topic_id: number;
|
||||
// Both lists are rule_brief's output — the SAME builder, so they are
|
||||
// described the same way here rather than as two hand-written shapes that
|
||||
// drift from it and from each other (which is what the server side had).
|
||||
rules: (RuleHeader & {
|
||||
topic_title: string;
|
||||
rulebook_id: number;
|
||||
rulebook_title: string;
|
||||
}[];
|
||||
project_rules: {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
}[];
|
||||
})[];
|
||||
project_rules: RuleHeader[];
|
||||
suppressed_rules: {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -133,14 +195,91 @@ export async function getRule(id: number): Promise<Rule> {
|
||||
return apiGet(`/api/rules/${id}`);
|
||||
}
|
||||
|
||||
export async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string; order_index?: number }): Promise<Rule> {
|
||||
/**
|
||||
* The fields both write paths accept. `system_ids` REPLACES a rule's areas.
|
||||
*
|
||||
* Sending "" for a nullable text field CLEARS it here — the server maps an
|
||||
* empty string to NULL, so an emptied form input does what it looks like it
|
||||
* does. (The MCP door reads "" as "leave unchanged" and needs an explicit
|
||||
* clear_fields list instead; the two idioms reach the same state.)
|
||||
*/
|
||||
export interface RuleWrite {
|
||||
title: string;
|
||||
statement: string;
|
||||
when_to_apply: string;
|
||||
tier: RuleTier;
|
||||
why: string;
|
||||
how_to_apply: string;
|
||||
order_index: number;
|
||||
system_ids: number[];
|
||||
arose_from_id: number | null;
|
||||
verify_with: string;
|
||||
expires_when: string;
|
||||
}
|
||||
|
||||
export async function createRule(topicId: number, data: Partial<RuleWrite> & { title: string; statement: string }): Promise<Rule> {
|
||||
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
|
||||
}
|
||||
|
||||
export async function updateRule(id: number, data: Partial<{ title: string; statement: string; why: string; how_to_apply: string; order_index: number }>): Promise<Rule> {
|
||||
export async function updateRule(id: number, data: Partial<RuleWrite>): Promise<Rule> {
|
||||
return apiPatch(`/api/rules/${id}`, data);
|
||||
}
|
||||
|
||||
/** Draw a typed edge from one rule to another. Idempotent. */
|
||||
export async function relateRules(
|
||||
fromRuleId: number,
|
||||
data: { to_rule_id: number; kind: RuleRelationKind; note?: string },
|
||||
): Promise<{ id: number }> {
|
||||
return apiPost(`/api/rules/${fromRuleId}/relations`, data);
|
||||
}
|
||||
|
||||
export async function unrelateRules(relationId: number): Promise<void> {
|
||||
return apiDelete(`/api/rule-relations/${relationId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry in a rule's edit history.
|
||||
*
|
||||
* Each entry holds the text the edit REPLACED, not the text it introduced —
|
||||
* so the newest entry is what the rule said before its most recent change,
|
||||
* and what that change produced is the rule as it stands now. Read the other
|
||||
* way round, every diff comes out backwards.
|
||||
*
|
||||
* The listing form omits the long fields; open one to get them.
|
||||
*/
|
||||
export interface RuleVersion {
|
||||
id: number;
|
||||
rule_id: number;
|
||||
/** Who made the edit. Null when that account has since been deleted. */
|
||||
user_id: number | null;
|
||||
title: string;
|
||||
created_at: string;
|
||||
statement?: string;
|
||||
why?: string;
|
||||
how_to_apply?: string;
|
||||
when_to_apply?: string;
|
||||
tier?: string;
|
||||
verify_with?: string;
|
||||
expires_when?: string;
|
||||
}
|
||||
|
||||
export async function listRuleVersions(ruleId: number): Promise<RuleVersion[]> {
|
||||
const data = await apiGet<{ versions: RuleVersion[] }>(
|
||||
`/api/rules/${ruleId}/versions`,
|
||||
);
|
||||
return data.versions;
|
||||
}
|
||||
|
||||
export async function getRuleVersion(
|
||||
ruleId: number, versionId: number,
|
||||
): Promise<RuleVersion> {
|
||||
return apiGet<RuleVersion>(`/api/rules/${ruleId}/versions/${versionId}`);
|
||||
}
|
||||
|
||||
// No restoreRuleVersion, deliberately (milestone 323). Putting an old wording
|
||||
// back goes through updateRule, which snapshots what it replaces — so the
|
||||
// undo stays visible in the history like any other edit.
|
||||
|
||||
export async function deleteRule(id: number): Promise<void> {
|
||||
return apiDelete(`/api/rules/${id}`);
|
||||
}
|
||||
@@ -161,7 +300,7 @@ export async function getProjectApplicableRules(projectId: number): Promise<Appl
|
||||
|
||||
export async function createProjectRule(
|
||||
projectId: number,
|
||||
data: { statement: string; title?: string; why?: string; how_to_apply?: string },
|
||||
data: Partial<RuleWrite> & { statement: string },
|
||||
): Promise<Rule> {
|
||||
return apiPost(`/api/projects/${projectId}/rules`, data);
|
||||
}
|
||||
@@ -194,3 +333,59 @@ export async function includeAlwaysOnRulebook(projectId: number, rulebookId: num
|
||||
await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* One row of the staleness sweep. Unlike RuleHeader this carries the CHECK
|
||||
* in full — the reader is about to go and run it, so the text is the point
|
||||
* of the payload rather than the bloat a listing avoids.
|
||||
*/
|
||||
export interface RuleVerificationRow {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
tier: RuleTier;
|
||||
topic_id: number | null;
|
||||
project_id: number | null;
|
||||
when_to_apply: string;
|
||||
verify_with: string;
|
||||
expires_when: string;
|
||||
/** A date (YYYY-MM-DD), or the literal "never". */
|
||||
last_verified: string | null;
|
||||
/** Null when never verified — "never" is not zero days ago. */
|
||||
days_since_verified: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rules asserting a fact that may have gone false, oldest verification
|
||||
* first, never-checked at the top. Rules without a check never appear:
|
||||
* they are decisions, and there is nothing to go and check.
|
||||
*
|
||||
* Not filterable by project — a project reaches rules through project
|
||||
* scope, subscriptions, always-on rulebooks and exclusions, and a filter
|
||||
* missing one of those paths would under-report.
|
||||
*/
|
||||
export async function listRulesDueForVerification(opts: {
|
||||
olderThanDays?: number;
|
||||
tier?: RuleTier;
|
||||
neverOnly?: boolean;
|
||||
} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.olderThanDays) q.set("older_than_days", String(opts.olderThanDays));
|
||||
if (opts.tier) q.set("tier", opts.tier);
|
||||
if (opts.neverOnly) q.set("never_only", "true");
|
||||
const qs = q.toString();
|
||||
return apiGet(`/api/rules-due-for-verification${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a rule's check was RUN, and what it said.
|
||||
*
|
||||
* `stillTrue: false` writes nothing on purpose — a rule whose check failed
|
||||
* is not in a recordable state, it is wrong — so it stays at the top of the
|
||||
* sweep until someone corrects or retires it.
|
||||
*/
|
||||
export async function markRuleVerified(
|
||||
id: number, stillTrue = true,
|
||||
): Promise<Rule & { verified: boolean }> {
|
||||
return apiPost(`/api/rules/${id}/verify`, { still_true: stillTrue });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RecordUsage } from "@/types/usage";
|
||||
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
|
||||
/** One canonical location of a reusable thing. A snippet that unifies several
|
||||
@@ -50,15 +52,11 @@ export interface Snippet {
|
||||
owner?: string | null;
|
||||
}
|
||||
|
||||
/** How often a record was put in front of an agent versus actually opened.
|
||||
* A high `surfaced_count` with `pull_count: 0` is dead weight — it occupies a
|
||||
* slot in every future auto-inject menu while never being used. */
|
||||
export interface SnippetUsage {
|
||||
surfaced_count: number;
|
||||
pull_count: number;
|
||||
last_surfaced_at: string | null;
|
||||
last_pulled_at: string | null;
|
||||
}
|
||||
/** Kept as a name because every consumer here says "snippet usage" — but it IS
|
||||
* the shared shape, since rules answer the same question off their own table
|
||||
* (milestone 333). The reasoning lives on `RecordUsage`; duplicating the four
|
||||
* fields here is how the two drift. */
|
||||
export type SnippetUsage = RecordUsage;
|
||||
|
||||
/** Result of the last drift check — does the recorded location and code still
|
||||
* match source? The check runs agent-side (Scribe has no checkout); this is the
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
import type { CanonicalMatch } from "@/api/canonicalSystems";
|
||||
|
||||
export interface System {
|
||||
id: number;
|
||||
project_id: number;
|
||||
name: string;
|
||||
/**
|
||||
* The global area this System is an instance of, or null. Null is a valid
|
||||
* resting state — a project-specific area should stay unmapped.
|
||||
*/
|
||||
canonical_id: number | null;
|
||||
description: string;
|
||||
color: string | null;
|
||||
status: "active" | "archived";
|
||||
@@ -18,10 +24,23 @@ export async function listSystems(projectId: number): Promise<System[]> {
|
||||
return data.systems;
|
||||
}
|
||||
|
||||
/**
|
||||
* A created System, plus the catalog's answer about its name. An `exact`
|
||||
* catalog hit is applied by the server and arrives as a populated
|
||||
* `canonical_id`; an `overlap` is only OFFERED, and comes back here for the
|
||||
* caller to accept or ignore.
|
||||
*
|
||||
* A same-named System in this project is a 409 ApiError carrying
|
||||
* `{duplicate, existing_id}` — the same gate the MCP door enforces (#2482).
|
||||
*/
|
||||
export interface CreatedSystem extends System {
|
||||
canonical_suggestion?: CanonicalMatch;
|
||||
}
|
||||
|
||||
export async function createSystem(
|
||||
projectId: number,
|
||||
data: { name: string; description?: string; color?: string },
|
||||
): Promise<System> {
|
||||
data: { name: string; description?: string; color?: string; canonical_id?: number },
|
||||
): Promise<CreatedSystem> {
|
||||
return apiPost(`/api/projects/${projectId}/systems`, data);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -297,3 +297,83 @@
|
||||
background: var(--fs-action-destructive-hover);
|
||||
border-color: var(--fs-action-destructive-hover);
|
||||
}
|
||||
|
||||
/* ── Page container ─────────────────────────────────────────────────────────
|
||||
The one wrapper a top-level view sits in: page width from the layout
|
||||
tokens, centred, clipped horizontally so a wide child (a kanban, a table)
|
||||
scrolls inside itself instead of the page. ProjectListView, ProjectView and
|
||||
SnippetListView each carried this rule under their own name until #2903
|
||||
(milestone 299). */
|
||||
.page-container {
|
||||
max-width: var(--fs-layout-page-max);
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--fs-layout-page-pad);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* ── Form input (fs-surfaces, snippet #2336) ────────────────────────────────
|
||||
Inputs sit DARKER than the page they're on — an inset well rather than a
|
||||
raised panel; that inversion is what makes a field read as writable. The
|
||||
design system's recipe, verbatim; width/box-sizing stay the caller's
|
||||
(an inline select and a full-width textarea differ there). Three scoped
|
||||
copies of an older input recipe were folded into this in #2903. */
|
||||
.fs-input {
|
||||
background: var(--fs-surface-page);
|
||||
border: var(--fs-border);
|
||||
border-radius: var(--fs-radius-md);
|
||||
padding: var(--fs-space-2) var(--fs-space-3); /* 8px 12px */
|
||||
color: var(--fs-text-primary);
|
||||
font-family: var(--fs-font-body);
|
||||
font-size: var(--fs-size-body);
|
||||
transition: box-shadow var(--fs-dur-fast) var(--fs-ease);
|
||||
}
|
||||
.fs-input::placeholder { color: var(--fs-text-tertiary); }
|
||||
.fs-input:focus { outline: none; box-shadow: var(--fs-focus-ring); }
|
||||
.fs-input:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
|
||||
|
||||
/* Page scaffold + feedback text recipes (milestone 302, note 2917): name
|
||||
families the consumer map showed to be one recipe living in many views.
|
||||
A view keeps only its deviation as a scoped remainder/override. */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.page-header h1 { margin: 0; }
|
||||
|
||||
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
|
||||
.state-msg { color: var(--fs-text-tertiary); font-size: 0.9rem; }
|
||||
.empty-msg { color: var(--fs-text-tertiary); font-size: 0.875rem; }
|
||||
|
||||
.empty-title { font-size: 1rem; font-weight: 500; color: var(--fs-text-secondary); margin: 0 0 0.35rem; }
|
||||
.empty-sub { font-size: 0.85rem; color: var(--fs-text-tertiary); margin: 0 0 1rem; }
|
||||
|
||||
.required { color: var(--fs-error); }
|
||||
.field-hint { margin: 0.3rem 0 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||||
|
||||
/* --- usage badge ----------------------------------------------------------
|
||||
"surfaced N×, opened M×" on a list row, for any record kind the retrieval
|
||||
surfaces can choose: snippets and notes from note_usage_events, rules from
|
||||
rule_usage_events. Promoted here from SnippetListView's scoped block when
|
||||
the rule list needed the same chip (milestone 333 step 5) — a second scoped
|
||||
copy is how the ninth duplicated CSS family starts (#3207).
|
||||
|
||||
Geometry and colour only. A view keeps its own spacing as a scoped
|
||||
remainder, the way it does for every other recipe in this file. */
|
||||
.usage-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||
color: var(--fs-text-tertiary-fg);
|
||||
}
|
||||
|
||||
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
|
||||
than the danger one, because the record isn't broken, just unearned. */
|
||||
.usage-tag.usage-dead {
|
||||
background: color-mix(in srgb, var(--fs-warning) 18%, transparent);
|
||||
color: var(--fs-warning-fg);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/* The near-duplicate report, shared by KnowledgeView (notes/tasks) and
|
||||
SnippetListView (snippets) so the two reports read as one feature. Load
|
||||
with <style src="@/assets/dup-report.css" /> beside the view's scoped
|
||||
block; the view keeps only its own extras (.dup-claimed, .dup-action).
|
||||
Promoted from two identical scoped copies in #2903 (milestone 299). */
|
||||
.dup-panel {
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--fs-surface-hover);
|
||||
}
|
||||
.dup-empty,
|
||||
.dup-head {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.dup-empty { margin-bottom: 0; }
|
||||
.dup-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.dup-members {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1 1 20rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.dup-member {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
||||
color: var(--fs-text-primary);
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dup-member:hover { background: var(--fs-surface-hover); }
|
||||
.dup-score {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -15,18 +15,6 @@
|
||||
padding: 1rem 1.5rem 0.5rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Toolbar & inputs ── */
|
||||
.toolbar {
|
||||
@@ -78,7 +66,7 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
@@ -106,95 +94,6 @@
|
||||
.tag-check {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* ── Assist panel ── */
|
||||
.assist-panel {
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--fs-border-color);
|
||||
background: var(--fs-surface-raised);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.assist-panel-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.assist-panel-title {
|
||||
flex: 1;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.assist-panel-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 0.9rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
/* Section list */
|
||||
.assist-sections-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--fs-text-tertiary);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.assist-sections {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.assist-section-item {
|
||||
padding: 0.35rem 0.7rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
border-left: 3px solid transparent;
|
||||
color: var(--fs-text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.assist-section-item:hover {
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.assist-section-item.selected {
|
||||
border-left-color: var(--fs-accent);
|
||||
background: var(--fs-surface-raised);
|
||||
font-weight: 500;
|
||||
}
|
||||
.assist-empty,
|
||||
.assist-hint {
|
||||
padding: 0.6rem 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.assist-target-preview {
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.assist-target-preview em {
|
||||
font-style: normal;
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.assist-instruction {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.65rem;
|
||||
@@ -213,33 +112,6 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Streaming */
|
||||
.assist-streaming-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.assist-preview-box {
|
||||
padding: 0.65rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
font-size: 0.9rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.typing-indicator {
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.15em;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Active hint shown in the panel while output is inline */
|
||||
.assist-active-hint {
|
||||
padding: 0.5rem 0.75rem;
|
||||
@@ -255,17 +127,7 @@
|
||||
border: 1px solid var(--fs-error);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-error);
|
||||
}
|
||||
|
||||
/* Review / diff */
|
||||
.assist-review-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-secondary);
|
||||
color: var(--fs-error-fg);
|
||||
}
|
||||
.diff-view {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
@@ -285,11 +147,11 @@
|
||||
}
|
||||
.diff-delete {
|
||||
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
|
||||
color: var(--fs-error);
|
||||
color: var(--fs-error-fg);
|
||||
}
|
||||
.diff-insert {
|
||||
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
|
||||
color: var(--fs-success);
|
||||
color: var(--fs-success-fg);
|
||||
}
|
||||
.diff-equal {
|
||||
color: var(--fs-text-tertiary);
|
||||
@@ -398,22 +260,9 @@
|
||||
|
||||
/* ── Mobile ── */
|
||||
@media (max-width: 768px) {
|
||||
.editor-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
.assist-panel {
|
||||
width: auto;
|
||||
flex: 0 0 45%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg) var(--fs-radius-lg) 0 0;
|
||||
}
|
||||
.editor-header {
|
||||
padding: 0.75rem 1rem 0.5rem;
|
||||
}
|
||||
.editor-main {
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
@@ -508,3 +357,36 @@
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Shared by NoteEditorView and TaskEditorView — both carried identical scoped
|
||||
copies of these until #2903 (milestone 299); one source here. */
|
||||
.body-tabs-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.body-editor-wrap {
|
||||
min-height: 200px;
|
||||
}
|
||||
.stream-preview {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.75rem;
|
||||
background: var(--fs-surface-raised);
|
||||
min-height: 200px;
|
||||
}
|
||||
.main-diff {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.assist-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/* Shared by the rules panes (RulebookListPane, RuleListPane,
|
||||
RulebookDetailPane, RuleSweepPane): the pane surface, its heading, and the
|
||||
title chip. Counting them in this comment went stale the first time a
|
||||
fourth was added, so it no longer does. Load with
|
||||
<style src="@/assets/rules-shared.css" /> beside the component's own
|
||||
scoped block; never restate these there (#2903, milestone 299). */
|
||||
.pane {
|
||||
background: var(--fs-surface-hover);
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pane header h2 {
|
||||
font-family: Fraunces, serif;
|
||||
font-style: italic;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
.form-buttons { display: flex; gap: 0.5rem; }
|
||||
|
||||
/* A small marker beside a rule's title. Two of these appeared within one
|
||||
milestone (tier, then verification) and were byte-identical; a third would
|
||||
have drifted. The pane's italic serif title is inherited by anything inside
|
||||
it, so the chip resets family and style explicitly. */
|
||||
.rule-chip {
|
||||
margin-left: 0.4rem;
|
||||
font-family: var(--fs-font-body);
|
||||
font-style: normal;
|
||||
font-size: 0.62rem;
|
||||
color: var(--fs-text-secondary);
|
||||
background: var(--fs-surface-raised);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.05rem 0.4rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -12,6 +12,13 @@
|
||||
file used to read, and it is deliberate: the light palette was never specified
|
||||
by any rule, so it is recorded as a departure rather than as the default.
|
||||
|
||||
The -fg tokens are a badge's TEXT colour, added because the ladder used its
|
||||
raw hue as text on a 12% tint of the same hue — measured 1.60-2.97:1 on the
|
||||
dark palette against the kit's AA floor of 4.5. Each is the hue mixed toward
|
||||
--fs-text-primary until it clears 4.5:1 worst-case over surface-raised and
|
||||
surface-hover in BOTH modes. Mixing toward that token is what makes one
|
||||
declaration cover both: it inverts, so the text follows the mode.
|
||||
|
||||
Only 12 tokens differ between modes. Everything else — spacing, type, motion,
|
||||
radius, and every derived colour — is stated once, because a value built with
|
||||
var() resolves where it is USED, not where it is written.
|
||||
@@ -24,6 +31,7 @@
|
||||
--fs-accent-faint: color-mix(in srgb, var(--fs-accent) 8%, transparent); /* The faintest accent wash */
|
||||
--fs-accent-deep: color-mix(in srgb, var(--fs-accent) 70%, black); /* The accent, darkened */
|
||||
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 22%, transparent); /* Heaviest accent tint */
|
||||
--fs-accent-fg: color-mix(in srgb, var(--fs-accent) 45%, var(--fs-text-primary)); /* Accent TEXT on an accent tint */
|
||||
--fs-gradient-cta: linear-gradient(135deg, var(--fs-accent), var(--fs-accent-deep));
|
||||
--fs-glow-cta: 0 2px 10px color-mix(in srgb, var(--fs-accent) 35%, transparent);
|
||||
--fs-glow-cta-hover: 0 4px 24px color-mix(in srgb, var(--fs-accent) 65%, transparent);
|
||||
@@ -78,10 +86,13 @@
|
||||
/* priority */
|
||||
--fs-priority-low: var(--fs-info);
|
||||
--fs-priority-low-bg: color-mix(in srgb, var(--fs-priority-low) 12%, transparent);
|
||||
--fs-priority-low-fg: color-mix(in srgb, var(--fs-priority-low) 45%, var(--fs-text-primary)); /* Badge TEXT for low priority — the readable partner of the -bg tint */
|
||||
--fs-priority-medium: var(--fs-warning);
|
||||
--fs-priority-medium-bg: color-mix(in srgb, var(--fs-priority-medium) 12%, transparent);
|
||||
--fs-priority-medium-fg: color-mix(in srgb, var(--fs-priority-medium) 55%, var(--fs-text-primary)); /* Badge TEXT for medium priority */
|
||||
--fs-priority-high: var(--fs-error);
|
||||
--fs-priority-high-bg: color-mix(in srgb, var(--fs-priority-high) 12%, transparent);
|
||||
--fs-priority-high-fg: color-mix(in srgb, var(--fs-priority-high) 55%, var(--fs-text-primary)); /* Badge TEXT for high priority */
|
||||
|
||||
/* radius */
|
||||
--fs-radius-sm: 4px; /* pills, tags, code spans */
|
||||
@@ -92,8 +103,11 @@
|
||||
|
||||
/* semantic */
|
||||
--fs-success: var(--fs-action-primary);
|
||||
--fs-success-fg: color-mix(in srgb, var(--fs-success) 45%, var(--fs-text-primary)); /* Success TEXT on a success tint */
|
||||
--fs-warning: #8B6F1E;
|
||||
--fs-warning-fg: color-mix(in srgb, var(--fs-warning) 50%, var(--fs-text-primary)); /* Warning TEXT on a warning tint */
|
||||
--fs-error: #C04A1F;
|
||||
--fs-error-fg: color-mix(in srgb, var(--fs-error) 50%, var(--fs-text-primary)); /* Error TEXT on an error tint */
|
||||
--fs-info: #3D5A6E;
|
||||
--fs-destructive: #6B2118; /* irreversible — deliberately not the error colour */
|
||||
|
||||
@@ -116,12 +130,16 @@
|
||||
/* status */
|
||||
--fs-status-todo: var(--fs-border-color);
|
||||
--fs-status-todo-bg: color-mix(in srgb, var(--fs-status-todo) 12%, transparent);
|
||||
--fs-status-todo-fg: color-mix(in srgb, var(--fs-status-todo) 40%, var(--fs-text-primary)); /* Badge TEXT for a not-started task */
|
||||
--fs-status-in-progress: var(--fs-accent);
|
||||
--fs-status-in-progress-bg: color-mix(in srgb, var(--fs-status-in-progress) 12%, transparent);
|
||||
--fs-status-in-progress-fg: color-mix(in srgb, var(--fs-status-in-progress) 45%, var(--fs-text-primary)); /* Badge TEXT for a task underway */
|
||||
--fs-status-done: var(--fs-success);
|
||||
--fs-status-done-bg: color-mix(in srgb, var(--fs-status-done) 12%, transparent);
|
||||
--fs-status-done-fg: color-mix(in srgb, var(--fs-status-done) 50%, var(--fs-text-primary)); /* Badge TEXT for a completed task */
|
||||
--fs-overdue: var(--fs-error);
|
||||
--fs-status-cancelled: var(--fs-text-tertiary); /* set aside, not failed */
|
||||
--fs-status-cancelled-fg: color-mix(in srgb, var(--fs-status-cancelled) 60%, var(--fs-text-primary)); /* Badge TEXT for a cancelled task */
|
||||
|
||||
/* surface */
|
||||
--fs-surface-page: #14171A; /* page bg, deepest surface */
|
||||
@@ -134,7 +152,9 @@
|
||||
/* text */
|
||||
--fs-text-primary: #E8E4D8; /* body, headings, labels — inverts by mode */
|
||||
--fs-text-secondary: #C2BFB4;
|
||||
--fs-text-secondary-fg: color-mix(in srgb, var(--fs-text-secondary) 90%, var(--fs-text-primary)); /* Secondary TEXT on a secondary tint (barely moves; no exceptions) */
|
||||
--fs-text-tertiary: #9C9A92;
|
||||
--fs-text-tertiary-fg: color-mix(in srgb, var(--fs-text-tertiary) 55%, var(--fs-text-primary)); /* Tertiary TEXT on a tertiary tint */
|
||||
--fs-text-on-action: #E8E4D8; /* text on a filled colour — NOT mode-dependent */
|
||||
|
||||
/* type */
|
||||
@@ -272,17 +292,10 @@ button:not(:disabled):active,
|
||||
display: none !important;
|
||||
}
|
||||
button,
|
||||
[role="button"],
|
||||
.btn-new-conv,
|
||||
.btn-send {
|
||||
[role="button"] {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.hide-desktop {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Neutral hairline scrollbars — chrome is structural, not branded */
|
||||
::-webkit-scrollbar {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.ctx-crumb-project {
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||
text-decoration: none;
|
||||
|
||||
@@ -206,49 +206,12 @@ router.afterEach(() => {
|
||||
background: var(--fs-accent-soft);
|
||||
}
|
||||
.nav-link.router-link-active {
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
font-weight: 500;
|
||||
background: color-mix(in srgb, var(--fs-accent) 25%, transparent);
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
cursor: default;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
/* Status dots are indicator lights, not semantic-palette buttons —
|
||||
they want to read as vital (Moss/Warning/Error are too muted for
|
||||
a "ready" indicator). Hardcoded bright values; the rest of the
|
||||
system still uses the semantic tokens. */
|
||||
.status-green .status-dot { background: #4ade80; animation: status-pulse 2.5s ease-in-out infinite; }
|
||||
.status-yellow .status-dot { background: #facc15; animation: pulse-dot 2s infinite; }
|
||||
.status-orange .status-dot { background: #f97316; }
|
||||
.status-red .status-dot { background: #ef4444; }
|
||||
.status-gray .status-dot { background: var(--fs-text-tertiary); animation: pulse-dot 2s infinite; }
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
@keyframes status-pulse {
|
||||
0%, 100% { box-shadow: 0 0 4px rgba(74, 222, 128, 0.4); }
|
||||
50% { box-shadow: 0 0 10px rgba(74, 222, 128, 0.6); }
|
||||
}
|
||||
|
||||
/* Icon buttons (?, theme, gear) */
|
||||
.btn-icon {
|
||||
background: none;
|
||||
@@ -263,8 +226,7 @@ router.afterEach(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-icon:hover,
|
||||
.btn-icon.active {
|
||||
.btn-icon:hover {
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
border-color: var(--fs-accent);
|
||||
@@ -295,7 +257,7 @@ router.afterEach(() => {
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
@@ -382,7 +344,6 @@ router.afterEach(() => {
|
||||
.nav-center {
|
||||
display: none;
|
||||
}
|
||||
.status-indicator,
|
||||
.btn-icon,
|
||||
.user-info {
|
||||
display: none;
|
||||
|
||||
@@ -137,12 +137,12 @@ function markerFor(type: DiffLine['type']): string {
|
||||
|
||||
.diff-delete {
|
||||
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
|
||||
color: var(--fs-error);
|
||||
color: var(--fs-error-fg);
|
||||
}
|
||||
|
||||
.diff-insert {
|
||||
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
|
||||
color: var(--fs-success);
|
||||
color: var(--fs-success-fg);
|
||||
}
|
||||
|
||||
.diff-equal {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import type { DiffLine } from "@/composables/useAssist";
|
||||
import { computeDiff, type DiffLine } from "@/utils/diff";
|
||||
import { fmtStamp } from "@/utils/dateFormat";
|
||||
|
||||
interface NoteVersion {
|
||||
@@ -33,28 +33,8 @@ const loadingDetail = ref(false);
|
||||
|
||||
const diff = computed<DiffLine[]>(() => {
|
||||
if (!selectedVersion.value?.body) return [];
|
||||
const a = props.currentBody;
|
||||
const b = selectedVersion.value.body;
|
||||
|
||||
const aLines = a.split('\n');
|
||||
const bLines = b.split('\n');
|
||||
const m = aLines.length, n = bLines.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = m - 1; i >= 0; i--)
|
||||
for (let j = n - 1; j >= 0; j--)
|
||||
dp[i][j] = aLines[i] === bLines[j]
|
||||
? dp[i+1][j+1] + 1
|
||||
: Math.max(dp[i+1][j], dp[i][j+1]);
|
||||
const result: DiffLine[] = [];
|
||||
let i = 0, j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (aLines[i] === bLines[j]) { result.push({ type: 'equal', text: aLines[i++] }); j++; }
|
||||
else if (dp[i+1][j] >= dp[i][j+1]) result.push({ type: 'delete', text: aLines[i++] });
|
||||
else result.push({ type: 'insert', text: bLines[j++] });
|
||||
}
|
||||
while (i < m) result.push({ type: 'delete', text: aLines[i++] });
|
||||
while (j < n) result.push({ type: 'insert', text: bLines[j++] });
|
||||
return result;
|
||||
return computeDiff(props.currentBody, selectedVersion.value.body);
|
||||
});
|
||||
|
||||
async function loadVersions() {
|
||||
|
||||
@@ -185,5 +185,4 @@ onMounted(load);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.inception-actions { display: flex; justify-content: flex-end; margin-top: 0.5rem; }
|
||||
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
|
||||
</style>
|
||||
|
||||
@@ -227,11 +227,11 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
.iap-diff-equal { color: var(--fs-text-tertiary); }
|
||||
.iap-diff-delete {
|
||||
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
|
||||
color: var(--fs-error);
|
||||
color: var(--fs-error-fg);
|
||||
}
|
||||
.iap-diff-insert {
|
||||
background: color-mix(in srgb, var(--fs-success) 10%, transparent);
|
||||
color: var(--fs-success);
|
||||
color: var(--fs-success-fg);
|
||||
}
|
||||
|
||||
.iap-diff-marker {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A task's KIND, shown on a list row — issue, spike, or a legacy plan.
|
||||
*
|
||||
* Sibling of PriorityBadge, and shaped like it on purpose: same geometry, and
|
||||
* the same rule that the DEFAULT value renders nothing. `work` is most tasks,
|
||||
* so badging it would put a chip on nearly every row and say nothing — the
|
||||
* same reason RuleListPane marks only `conditional`.
|
||||
*
|
||||
* Kind is not status. A task can be an in-progress issue or a done spike;
|
||||
* this answers "what kind of work is this", never "how is it going".
|
||||
*/
|
||||
import type { TaskKind } from "@/types/note";
|
||||
|
||||
const props = defineProps<{ kind?: TaskKind | null }>();
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
issue: "Issue",
|
||||
spike: "Spike",
|
||||
plan: "Plan",
|
||||
};
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
issue: "Corrective work — something was broken",
|
||||
spike: "Time-boxed investigation — the output is an answer, not a change",
|
||||
plan: "Legacy plan-task; plans are milestones now",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="props.kind && LABELS[props.kind]"
|
||||
:class="['kind-badge', `kind-${props.kind}`]"
|
||||
:title="TITLES[props.kind]"
|
||||
>{{ LABELS[props.kind] }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kind-badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
/* 500, not the 600 StatusBadge and PriorityBadge use. The house style
|
||||
allows two weights, 400 and 500 — those two predate the constraint and
|
||||
copying them would spread it. */
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Issue and spike are opposite in character — corrective vs exploratory — so
|
||||
they are split by TEMPERATURE, warm against cool, which survives being
|
||||
small and stays distinguishable without relying on reading the word.
|
||||
Neither uses the accent: one accent per app, and kind is not one of the
|
||||
places it is allowed.
|
||||
The text is the hue mixed toward --fs-text-primary rather than the raw
|
||||
semantic colour. Raw fails the contrast floor on the dark palette —
|
||||
measured: warning on its own 12% tint is 2.97:1, well under AA's 4.5.
|
||||
Mixing toward the text token also makes these follow the mode for free,
|
||||
since that token inverts. Measured both ways: issue 5.23:1 dark / 6.68:1
|
||||
light, spike 5.33:1 / 9.26:1. */
|
||||
.kind-issue {
|
||||
background: color-mix(in srgb, var(--fs-warning) 14%, var(--fs-surface-raised));
|
||||
color: color-mix(in srgb, var(--fs-warning) 60%, var(--fs-text-primary));
|
||||
}
|
||||
.kind-spike {
|
||||
background: color-mix(in srgb, var(--fs-info) 14%, var(--fs-surface-raised));
|
||||
color: color-mix(in srgb, var(--fs-info) 50%, var(--fs-text-primary));
|
||||
}
|
||||
/* Retired since 0066 — deliberately hue-free so a legacy row reads as
|
||||
archival rather than as a fourth active kind competing for attention. */
|
||||
.kind-plan {
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -156,7 +156,7 @@ const groups = [
|
||||
|
||||
.md-btn.active {
|
||||
background: color-mix(in srgb, var(--fs-accent) 14%, transparent);
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--fs-accent) 35%, transparent);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ function onChange(e: Event) {
|
||||
|
||||
<template>
|
||||
<select
|
||||
class="milestone-select"
|
||||
class="fs-input milestone-select"
|
||||
:value="modelValue ?? ''"
|
||||
:disabled="!projectId || loading"
|
||||
@change="onChange"
|
||||
@@ -64,23 +64,10 @@ function onChange(e: Event) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* The input itself is the .fs-input canon (components.css); only the
|
||||
layout remainder lives here. */
|
||||
.milestone-select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.milestone-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.milestone-select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The staleness sweep for NOTES: notes that assert a fact, oldest first.
|
||||
*
|
||||
* Sibling of RuleSweepPane, not a shared component — the two read differently
|
||||
* enough that merging them would mean a prop for every difference (a rule has
|
||||
* a tier and a statement; a note has a project and opens at a route). What
|
||||
* they share is the SHAPE of the judgement, and that is worth copying
|
||||
* deliberately rather than abstracting: the ordering carries urgency, "never"
|
||||
* is categorically different from a date, and a failed check writes nothing.
|
||||
*
|
||||
* Lives in the Knowledge view rather than beside the rules sweep (operator's
|
||||
* call, milestone 317 step 4): notes stay where notes live. The cost, accepted
|
||||
* knowingly, is that there is no single screen showing every record anyone has
|
||||
* left unconfirmed — /rules keeps its own.
|
||||
*/
|
||||
import { onMounted, ref } from "vue";
|
||||
import { apiGet, apiPost } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
interface DueNote {
|
||||
id: number;
|
||||
title: string;
|
||||
project_id: number | null;
|
||||
verify_with: string;
|
||||
expires_when: string;
|
||||
last_verified: string | null;
|
||||
days_since_verified: number | null;
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ "open-note": [id: number] }>();
|
||||
|
||||
const toast = useToastStore();
|
||||
const rows = ref<DueNote[]>([]);
|
||||
const loading = ref(false);
|
||||
const neverOnly = ref(false);
|
||||
const busyId = ref<number | null>(null);
|
||||
|
||||
async function reload() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const p = new URLSearchParams();
|
||||
if (neverOnly.value) p.set("never_only", "1");
|
||||
const data = await apiGet<{ notes: DueNote[] }>(
|
||||
`/api/notes/due-for-verification?${p}`,
|
||||
);
|
||||
rows.value = data.notes;
|
||||
} catch {
|
||||
toast.show("Could not load the sweep", "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function verify(id: number, stillTrue: boolean) {
|
||||
busyId.value = id;
|
||||
try {
|
||||
await apiPost(`/api/notes/${id}/verify`, { still_true: stillTrue });
|
||||
if (stillTrue) {
|
||||
// It has been confirmed, so it leaves the list — the sweep shows what
|
||||
// still needs looking at, and leaving it in place would invite a second
|
||||
// stamp nobody earned.
|
||||
rows.value = rows.value.filter((r) => r.id !== id);
|
||||
toast.show("Recorded — checked today");
|
||||
} else {
|
||||
// It stays. A failed check writes nothing on purpose: the note is wrong
|
||||
// rather than in a state worth recording, so it keeps its place until
|
||||
// someone corrects, supersedes, or unhooks it.
|
||||
toast.show("Recorded as no longer true — the note keeps its place here");
|
||||
}
|
||||
} catch {
|
||||
toast.show("Could not record that", "error");
|
||||
} finally {
|
||||
busyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(reload);
|
||||
defineExpose({ reload });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="sweep">
|
||||
<header>
|
||||
<h2>Due for verification</h2>
|
||||
<p class="lede">
|
||||
Notes that assert a fact about something outside your control — what a
|
||||
service does, how a tool behaves. Most notes are decisions and never
|
||||
appear here; they have no truth value to go stale.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="filters">
|
||||
<label class="filter">
|
||||
<input v-model="neverOnly" type="checkbox" @change="reload" />
|
||||
<span>Never checked only</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="loading" class="state">Loading…</p>
|
||||
|
||||
<!-- An empty sweep is GOOD NEWS and must not read like a broken page. -->
|
||||
<p v-else-if="!rows.length" class="state empty">
|
||||
Nothing to check.
|
||||
{{ neverOnly
|
||||
? "Every note that carries a check has been confirmed at least once."
|
||||
: "No note carries a check yet — add one to a note that asserts a fact." }}
|
||||
</p>
|
||||
|
||||
<ol v-else class="rows">
|
||||
<li v-for="n in rows" :key="n.id" class="row">
|
||||
<div class="row-head">
|
||||
<button class="row-title" @click="emit('open-note', n.id)">{{ n.title }}</button>
|
||||
<span class="age" :class="{ unchecked: n.days_since_verified === null }">
|
||||
{{ n.days_since_verified === null
|
||||
? "never checked"
|
||||
: `${n.days_since_verified}d ago` }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dl class="check">
|
||||
<dt>Check</dt>
|
||||
<dd>{{ n.verify_with }}</dd>
|
||||
<template v-if="n.expires_when">
|
||||
<dt>Ends when</dt>
|
||||
<dd>{{ n.expires_when }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<div class="actions">
|
||||
<button :disabled="busyId === n.id" @click="verify(n.id, true)">Still true</button>
|
||||
<button :disabled="busyId === n.id" @click="verify(n.id, false)">No longer true</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p v-if="rows.length" class="footnote">
|
||||
Record a result only after actually running the check. “No longer true” stores nothing
|
||||
on purpose — the note is wrong rather than in a state worth recording, so it keeps its
|
||||
place here until you correct it, supersede it, or remove its check.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sweep { display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
h2 { margin: 0; font-size: 1.05rem; }
|
||||
.lede {
|
||||
margin: 0.35rem 0 0;
|
||||
max-width: 62ch;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.filters { display: flex; gap: var(--fs-space-5); align-items: center; flex-wrap: wrap; }
|
||||
.filter { display: flex; align-items: center; gap: var(--fs-space-2); font-size: 0.82rem; color: var(--fs-text-secondary); }
|
||||
.filter input[type="checkbox"] { accent-color: var(--fs-accent); }
|
||||
|
||||
.state { margin: 0; font-size: 0.9rem; color: var(--fs-text-secondary); }
|
||||
.state.empty { color: var(--fs-text-tertiary); }
|
||||
|
||||
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
.row {
|
||||
background: var(--fs-surface-raised);
|
||||
border-radius: var(--fs-radius-md);
|
||||
padding: var(--fs-space-3);
|
||||
}
|
||||
.row-head { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
|
||||
.row-title {
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
font-family: Fraunces, serif; font-style: italic; font-size: 1.02rem;
|
||||
color: var(--fs-text-primary); text-align: left;
|
||||
}
|
||||
.row-title:hover { text-decoration: underline; }
|
||||
/* The ORDER carries urgency — the top of this list is the least-confirmed
|
||||
thing in the corpus. No red/amber ramp: it would restate the ordering and
|
||||
force an invented "stale after N days" threshold. "Never" is marked because
|
||||
it is categorically DIFFERENT from a date, not a worse one. */
|
||||
.age { margin-left: auto; font-size: 0.78rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
|
||||
.age.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
|
||||
|
||||
.check { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem var(--fs-space-3); margin: var(--fs-space-3) 0 0; }
|
||||
.check dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fs-text-tertiary); }
|
||||
.check dd { margin: 0; font-size: 0.82rem; color: var(--fs-text-primary); min-width: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.actions { display: flex; gap: var(--fs-space-2); margin-top: var(--fs-space-3); }
|
||||
.actions button {
|
||||
cursor: pointer; font: inherit; font-size: 0.78rem;
|
||||
background: var(--fs-surface-page); color: var(--fs-text-primary);
|
||||
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
|
||||
padding: 0.25rem 0.6rem;
|
||||
}
|
||||
.actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
|
||||
.actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
|
||||
|
||||
.footnote { margin: 0; max-width: 62ch; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
||||
</style>
|
||||
@@ -3,6 +3,8 @@ import type { TaskPriority } from "@/types/task";
|
||||
|
||||
const props = defineProps<{
|
||||
priority: TaskPriority;
|
||||
/** Dense surfaces — see StatusBadge. */
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
const labels: Record<TaskPriority, string> = {
|
||||
@@ -16,7 +18,7 @@ const labels: Record<TaskPriority, string> = {
|
||||
<template>
|
||||
<span
|
||||
v-if="props.priority !== 'none'"
|
||||
:class="['priority-badge', `priority-${props.priority}`]"
|
||||
:class="['priority-badge', `priority-${props.priority}`, { compact }]"
|
||||
>
|
||||
{{ labels[props.priority] }}
|
||||
</span>
|
||||
@@ -28,20 +30,28 @@ const labels: Record<TaskPriority, string> = {
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
/* 500 is the heaviest the house style goes — 400 and 500 only. */
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
.compact {
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.7rem;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
.priority-low {
|
||||
background: var(--fs-priority-low-bg);
|
||||
color: var(--fs-priority-low);
|
||||
color: var(--fs-priority-low-fg);
|
||||
}
|
||||
.priority-medium {
|
||||
background: var(--fs-priority-medium-bg);
|
||||
color: var(--fs-priority-medium);
|
||||
color: var(--fs-priority-medium-fg);
|
||||
}
|
||||
.priority-high {
|
||||
background: var(--fs-priority-high-bg);
|
||||
color: var(--fs-priority-high);
|
||||
color: var(--fs-priority-high-fg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -153,7 +153,7 @@ watch(() => [props.projectId, props.designSystemId], run);
|
||||
}
|
||||
|
||||
.pdt-clean {
|
||||
color: var(--fs-status-done);
|
||||
color: var(--fs-status-done-fg);
|
||||
}
|
||||
|
||||
.pdt-summary {
|
||||
@@ -206,12 +206,12 @@ watch(() => [props.projectId, props.designSystemId], run);
|
||||
|
||||
.pdt-tag.unknown {
|
||||
background: var(--fs-priority-high-bg);
|
||||
color: var(--fs-priority-high);
|
||||
color: var(--fs-priority-high-fg);
|
||||
}
|
||||
|
||||
.pdt-tag.local {
|
||||
background: var(--fs-priority-medium-bg);
|
||||
color: var(--fs-priority-medium);
|
||||
color: var(--fs-priority-medium-fg);
|
||||
}
|
||||
|
||||
.pdt-tag.superseded {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A PROJECT's lifecycle state as a pill — active, paused, completed, archived.
|
||||
*
|
||||
* Deliberately not StatusBadge. That component is typed to TaskStatus and
|
||||
* speaks a different vocabulary; these two only ever shared a CSS class name,
|
||||
* which is what made them look like one shape that had drifted (#3132).
|
||||
*
|
||||
* Extracted because ProjectView and ProjectListView really were spelling the
|
||||
* same pill twice, with the differences you get from two hands rather than
|
||||
* two intentions: 0.68rem against 0.7rem, a 14% tint against 15%, one with a
|
||||
* border and one without.
|
||||
*/
|
||||
const props = defineProps<{ status: string }>();
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
active: "Active",
|
||||
paused: "Paused",
|
||||
completed: "Completed",
|
||||
archived: "Archived",
|
||||
};
|
||||
|
||||
const label = (s: string) => LABELS[s] ?? s;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="['project-status', `project-status--${props.status}`]">
|
||||
{{ label(props.status) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.project-status {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: var(--fs-radius-pill);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Text is the hue mixed toward --fs-text-primary, not the raw hue. Both old
|
||||
spellings painted the hue on a 15% tint of itself, which measured 1.61-2.39:1
|
||||
against AA's 4.5 — the same defect the status and priority ladders had, and
|
||||
invisible to the token checker because the background was an inline
|
||||
color-mix rather than a `-bg` token. The checker was widened alongside this.
|
||||
Measured worst-case over raised and hover in both modes: active 4.82:1,
|
||||
paused 4.63:1, completed 4.78:1, archived 4.84:1.
|
||||
No new design tokens: four values used by one component are the kind of
|
||||
growth Scribe's own design-system note warns about ("if this system grows
|
||||
past a handful of tokens, that is worth noticing rather than
|
||||
accommodating"). The derivation is stated once, here. */
|
||||
.project-status--active {
|
||||
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
|
||||
color: color-mix(in srgb, var(--fs-success) 45%, var(--fs-text-primary));
|
||||
}
|
||||
.project-status--paused {
|
||||
background: color-mix(in srgb, var(--fs-warning) 15%, transparent);
|
||||
color: color-mix(in srgb, var(--fs-warning) 55%, var(--fs-text-primary));
|
||||
}
|
||||
.project-status--completed {
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: color-mix(in srgb, var(--fs-accent) 45%, var(--fs-text-primary));
|
||||
}
|
||||
.project-status--archived {
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||
color: color-mix(in srgb, var(--fs-text-tertiary) 55%, var(--fs-text-primary));
|
||||
}
|
||||
</style>
|
||||
@@ -231,7 +231,6 @@ onMounted(async () => {
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
|
||||
.share-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
@@ -307,7 +306,6 @@ onMounted(async () => {
|
||||
.user-result-item:hover { background: var(--fs-surface-raised); }
|
||||
|
||||
.user-result-name { font-weight: 600; font-size: 0.88rem; }
|
||||
.user-result-email { color: var(--fs-text-tertiary); font-size: 0.8rem; }
|
||||
|
||||
.perm-select {
|
||||
padding: 0.45rem 0.5rem;
|
||||
|
||||
@@ -4,13 +4,16 @@ import type { TaskStatus } from "@/types/task";
|
||||
const props = defineProps<{
|
||||
status: TaskStatus;
|
||||
clickable?: boolean;
|
||||
/** Dense surfaces — smaller, unshouted. The canon (#2960) names compact a
|
||||
VARIANT of this component rather than a reason to re-spell it. */
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{ click: [] }>();
|
||||
|
||||
const labels: Record<TaskStatus, string> = {
|
||||
todo: "Todo",
|
||||
in_progress: "In Progress",
|
||||
in_progress: "In progress",
|
||||
done: "Done",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
@@ -18,7 +21,7 @@ const labels: Record<TaskStatus, string> = {
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="['status-badge', `status-${props.status}`, { clickable }]"
|
||||
:class="['status-badge', `status-${props.status}`, { clickable, compact }]"
|
||||
@click="clickable ? $emit('click') : undefined"
|
||||
:role="clickable ? 'button' : undefined"
|
||||
:tabindex="clickable ? 0 : undefined"
|
||||
@@ -33,25 +36,37 @@ const labels: Record<TaskStatus, string> = {
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
/* 500 is the heaviest the house style goes — 400 and 500 only. */
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
/* Text comes from the -fg tokens, which are the hue mixed toward
|
||||
--fs-text-primary until they clear AA. The old spelling darkened the hue
|
||||
with `#000 15%` — a light-mode instinct that made these WORSE on the dark
|
||||
palette, where the surface is already near-black, and a literal besides. */
|
||||
.status-todo {
|
||||
background: color-mix(in srgb, var(--fs-status-todo-bg) 78%, var(--fs-status-todo) 22%);
|
||||
color: color-mix(in srgb, var(--fs-status-todo) 85%, #000 15%);
|
||||
background: var(--fs-status-todo-bg);
|
||||
color: var(--fs-status-todo-fg);
|
||||
}
|
||||
.status-in_progress {
|
||||
background: color-mix(in srgb, var(--fs-status-in-progress-bg) 78%, var(--fs-status-in-progress) 22%);
|
||||
color: color-mix(in srgb, var(--fs-status-in-progress) 85%, #000 15%);
|
||||
background: var(--fs-status-in-progress-bg);
|
||||
color: var(--fs-status-in-progress-fg);
|
||||
}
|
||||
.status-done {
|
||||
background: color-mix(in srgb, var(--fs-status-done-bg) 78%, var(--fs-status-done) 22%);
|
||||
color: color-mix(in srgb, var(--fs-status-done) 85%, #000 15%);
|
||||
background: var(--fs-status-done-bg);
|
||||
color: var(--fs-status-done-fg);
|
||||
}
|
||||
.status-cancelled {
|
||||
background: color-mix(in srgb, var(--fs-surface-raised) 78%, var(--fs-text-tertiary) 22%);
|
||||
color: var(--fs-text-tertiary);
|
||||
background: var(--fs-status-todo-bg);
|
||||
color: var(--fs-status-cancelled-fg);
|
||||
}
|
||||
.compact {
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.7rem;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useSystemsStore } from "@/stores/systems";
|
||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { getProjectIssues } from "@/api/systems";
|
||||
import type { System, TaskLike } from "@/api/systems";
|
||||
import type { CanonicalMatch } from "@/api/canonicalSystems";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { Pencil, Trash2, Archive, ArchiveRestore } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{ projectId: number }>();
|
||||
|
||||
const store = useSystemsStore();
|
||||
const canon = useCanonicalSystemsStore();
|
||||
const toast = useToastStore();
|
||||
|
||||
const error = ref<string | null>(null);
|
||||
@@ -19,14 +23,26 @@ const issues = ref<TaskLike[]>([]);
|
||||
const showCreate = ref(false);
|
||||
const newName = ref("");
|
||||
const newDescription = ref("");
|
||||
// The global area, chosen explicitly. A PICKER rather than a live matcher on
|
||||
// purpose: reproducing the server's slug rule in TypeScript would give this
|
||||
// feature two matchers to keep in step, which is the exact drift the catalog
|
||||
// exists to end. The server still applies an exact hit on submit.
|
||||
const newCanonicalId = ref<number | null>(null);
|
||||
const creating = ref(false);
|
||||
// An `overlap` the server offered after a create — an offer, never applied.
|
||||
const suggestion = ref<{ systemId: number; match: CanonicalMatch } | null>(null);
|
||||
|
||||
// Edit state
|
||||
const editingId = ref<number | null>(null);
|
||||
const editName = ref("");
|
||||
const editDescription = ref("");
|
||||
const editCanonicalId = ref<number | null>(null);
|
||||
const savingEdit = ref(false);
|
||||
|
||||
// Mapping review
|
||||
const showReview = ref(false);
|
||||
const reviewBusy = ref<number | null>(null);
|
||||
|
||||
// Delete confirmation
|
||||
const deletingSystem = ref<System | null>(null);
|
||||
|
||||
@@ -37,6 +53,12 @@ const visibleSystems = computed(() =>
|
||||
showArchived.value ? systems.value : activeSystems.value,
|
||||
);
|
||||
|
||||
const proposals = computed(() => canon.proposalsByProject[props.projectId] ?? []);
|
||||
|
||||
function areaName(system: System): string | null {
|
||||
return canon.byId(system.canonical_id)?.name ?? null;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
error.value = null;
|
||||
try {
|
||||
@@ -49,6 +71,14 @@ async function load() {
|
||||
} catch {
|
||||
issues.value = [];
|
||||
}
|
||||
// Both fail soft: the catalog is a naming aid, and a review prompt that
|
||||
// cannot load must not take the Systems list down with it.
|
||||
await canon.fetchCatalog();
|
||||
try {
|
||||
await canon.fetchProposals(props.projectId);
|
||||
} catch {
|
||||
/* no proposals shown */
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
@@ -58,12 +88,14 @@ function openCreate() {
|
||||
showCreate.value = true;
|
||||
newName.value = "";
|
||||
newDescription.value = "";
|
||||
newCanonicalId.value = null;
|
||||
}
|
||||
|
||||
function cancelCreate() {
|
||||
showCreate.value = false;
|
||||
newName.value = "";
|
||||
newDescription.value = "";
|
||||
newCanonicalId.value = null;
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
@@ -71,23 +103,60 @@ async function submitCreate() {
|
||||
if (!name || creating.value) return;
|
||||
creating.value = true;
|
||||
try {
|
||||
await store.createSystem(props.projectId, {
|
||||
const created = await store.createSystem(props.projectId, {
|
||||
name,
|
||||
description: newDescription.value.trim() || undefined,
|
||||
canonical_id: newCanonicalId.value ?? undefined,
|
||||
});
|
||||
cancelCreate();
|
||||
toast.show("System created");
|
||||
} catch {
|
||||
toast.show("Failed to create system", "error");
|
||||
if (created.canonical_suggestion) {
|
||||
// An overlap: shown as an offer beside the new System, never applied.
|
||||
suggestion.value = { systemId: created.id, match: created.canonical_suggestion };
|
||||
}
|
||||
toast.show(
|
||||
created.canonical_id
|
||||
? `System created and filed under ${canon.byId(created.canonical_id)?.name}`
|
||||
: "System created",
|
||||
);
|
||||
} catch (e) {
|
||||
// 409 = this project already has that System. Say WHICH one, so the
|
||||
// answer is actionable rather than "it didn't work".
|
||||
toast.show(apiErrorMessage(e, "Failed to create system"), "error");
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptSuggestion() {
|
||||
const pending = suggestion.value;
|
||||
if (!pending) return;
|
||||
suggestion.value = null;
|
||||
try {
|
||||
await canon.mapSystem(props.projectId, pending.systemId, pending.match.id);
|
||||
await store.fetchSystems(props.projectId);
|
||||
toast.show(`Filed under ${pending.match.name}`);
|
||||
} catch {
|
||||
/* the store already reported it */
|
||||
}
|
||||
}
|
||||
|
||||
async function applyProposal(systemId: number, canonicalId: number) {
|
||||
reviewBusy.value = systemId;
|
||||
try {
|
||||
await canon.mapSystem(props.projectId, systemId, canonicalId);
|
||||
await store.fetchSystems(props.projectId);
|
||||
} catch {
|
||||
/* the store already reported it */
|
||||
} finally {
|
||||
reviewBusy.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(system: System) {
|
||||
editingId.value = system.id;
|
||||
editName.value = system.name;
|
||||
editDescription.value = system.description;
|
||||
editCanonicalId.value = system.canonical_id;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
@@ -103,6 +172,12 @@ async function submitEdit(system: System) {
|
||||
name,
|
||||
description: editDescription.value.trim(),
|
||||
});
|
||||
// The mapping is a separate write with its own validation — one column,
|
||||
// one writer (services/canonical_systems.set_system_canonical).
|
||||
if (editCanonicalId.value !== system.canonical_id) {
|
||||
await canon.mapSystem(props.projectId, system.id, editCanonicalId.value);
|
||||
await store.fetchSystems(props.projectId);
|
||||
}
|
||||
editingId.value = null;
|
||||
toast.show("System updated");
|
||||
} catch {
|
||||
@@ -161,6 +236,65 @@ async function confirmDelete() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Mapping review. Only appears when there is something to decide, and
|
||||
it says HOW MANY rather than nagging with a permanent banner. -->
|
||||
<div v-if="proposals.length" class="area-review">
|
||||
<button class="area-review-head" @click="showReview = !showReview">
|
||||
<span class="area-review-count">{{ proposals.length }}</span>
|
||||
{{ proposals.length === 1 ? "system" : "systems" }} may belong to a shared area
|
||||
<span class="area-review-chev">{{ showReview ? "▾" : "▸" }}</span>
|
||||
</button>
|
||||
<ul v-if="showReview" class="area-proposals">
|
||||
<li v-for="p in proposals" :key="p.system_id" class="area-proposal">
|
||||
<div class="area-proposal-text">
|
||||
<span class="area-proposal-name">{{ p.system_name }}</span>
|
||||
<span class="area-proposal-arrow" aria-hidden="true">→</span>
|
||||
<span class="area-proposal-target">{{ p.canonical_name }}</span>
|
||||
<!-- The basis is the decision the reviewer is making: `exact`
|
||||
differs only in spelling, `overlap` is a judgment call.
|
||||
Showing them identically is how a wrong mapping is waved
|
||||
through, so they never share a style. -->
|
||||
<span
|
||||
class="area-basis"
|
||||
:class="p.basis === 'exact' ? 'area-basis--exact' : 'area-basis--overlap'"
|
||||
:title="
|
||||
p.basis === 'exact'
|
||||
? 'Same name up to spelling — safe to accept.'
|
||||
: 'Shares a word. Accept only if it is really the same area.'
|
||||
"
|
||||
>{{ p.basis === "exact" ? "same name" : "similar" }}</span>
|
||||
</div>
|
||||
<div class="area-proposal-actions">
|
||||
<button
|
||||
class="btn-primary btn-compact"
|
||||
:disabled="reviewBusy === p.system_id"
|
||||
@click="applyProposal(p.system_id, p.canonical_id)"
|
||||
>
|
||||
{{ reviewBusy === p.system_id ? "Filing…" : "File here" }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
@click="canon.dismissProposal(props.projectId, p.system_id)"
|
||||
>
|
||||
Not this
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- An overlap offered by the server after a create. Never applied. -->
|
||||
<div v-if="suggestion" class="area-offer">
|
||||
<span>
|
||||
Is this the same area as
|
||||
<strong>{{ suggestion.match.name }}</strong>?
|
||||
</span>
|
||||
<div class="area-proposal-actions">
|
||||
<button class="btn-primary btn-compact" @click="acceptSuggestion">File it there</button>
|
||||
<button class="btn-ghost btn-compact" @click="suggestion = null">No, it's ours</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="systems-toolbar">
|
||||
<button v-if="!showCreate" class="btn-ghost btn-inline btn-add-system" @click="openCreate">
|
||||
@@ -176,7 +310,7 @@ async function confirmDelete() {
|
||||
<form v-if="showCreate" class="system-form" @submit.prevent="submitCreate">
|
||||
<input
|
||||
v-model="newName"
|
||||
class="system-input"
|
||||
class="fs-input system-input"
|
||||
placeholder="System name"
|
||||
aria-label="System name"
|
||||
autofocus
|
||||
@@ -184,11 +318,25 @@ async function confirmDelete() {
|
||||
/>
|
||||
<textarea
|
||||
v-model="newDescription"
|
||||
class="system-textarea"
|
||||
class="fs-input system-textarea"
|
||||
rows="2"
|
||||
placeholder="What is this subsystem responsible for? (optional)"
|
||||
aria-label="System description"
|
||||
></textarea>
|
||||
<label v-if="canon.catalog.length" class="area-field">
|
||||
<span class="area-label">Shared area</span>
|
||||
<select v-model="newCanonicalId" class="fs-input area-select" aria-label="Shared area">
|
||||
<option :value="null">None — specific to this project</option>
|
||||
<option v-for="entry in canon.catalog" :key="entry.id" :value="entry.id">
|
||||
{{ entry.name }}
|
||||
</option>
|
||||
</select>
|
||||
<!-- .field-hint is the shared hint class beside .fs-input
|
||||
(components.css) — not restated scoped. -->
|
||||
<span class="field-hint">
|
||||
Files this system under an area shared by every project. Your name stays as you typed it.
|
||||
</span>
|
||||
</label>
|
||||
<div class="system-form-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
|
||||
{{ creating ? "Creating…" : "Create" }}
|
||||
@@ -227,7 +375,7 @@ async function confirmDelete() {
|
||||
<form class="system-form system-form--inline" @submit.prevent="submitEdit(system)">
|
||||
<input
|
||||
v-model="editName"
|
||||
class="system-input"
|
||||
class="fs-input system-input"
|
||||
placeholder="System name"
|
||||
aria-label="System name"
|
||||
autofocus
|
||||
@@ -235,11 +383,20 @@ async function confirmDelete() {
|
||||
/>
|
||||
<textarea
|
||||
v-model="editDescription"
|
||||
class="system-textarea"
|
||||
class="fs-input system-textarea"
|
||||
rows="2"
|
||||
placeholder="Description (optional)"
|
||||
aria-label="System description"
|
||||
></textarea>
|
||||
<label v-if="canon.catalog.length" class="area-field">
|
||||
<span class="area-label">Shared area</span>
|
||||
<select v-model="editCanonicalId" class="fs-input area-select" aria-label="Shared area">
|
||||
<option :value="null">None — specific to this project</option>
|
||||
<option v-for="entry in canon.catalog" :key="entry.id" :value="entry.id">
|
||||
{{ entry.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="system-form-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
|
||||
{{ savingEdit ? "Saving…" : "Save" }}
|
||||
@@ -264,6 +421,13 @@ async function confirmDelete() {
|
||||
:title="`${system.open_issue_count} open issue(s)`"
|
||||
>{{ system.open_issue_count }} open</span>
|
||||
<span v-if="system.status === 'archived'" class="archived-badge">Archived</span>
|
||||
<!-- Not a TagPill: that recipe prefixes "#" and means a tag.
|
||||
This is the shared AREA this system is an instance of. -->
|
||||
<span
|
||||
v-if="areaName(system)"
|
||||
class="area-chip"
|
||||
:title="`Filed under the shared area “${areaName(system)}” — records and rules about this area line up across projects.`"
|
||||
>{{ areaName(system) }}</span>
|
||||
</div>
|
||||
<p v-if="system.description" class="system-description">{{ system.description }}</p>
|
||||
</div>
|
||||
@@ -335,6 +499,91 @@ async function confirmDelete() {
|
||||
.issue-systems { display: flex; gap: 0.25rem; flex-shrink: 0; flex-wrap: wrap; }
|
||||
.issue-sys-chip { font-size: 0.66rem; color: var(--fs-text-secondary); background: var(--fs-surface-raised); border-radius: 999px; padding: 0.05rem 0.4rem; }
|
||||
|
||||
/* ── Shared-area mapping (milestone 307) ──────────────────────────
|
||||
The review is a disclosure, not a banner: it exists only while there is
|
||||
something to decide, and collapses to one line until opened. */
|
||||
.area-review {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.area-review-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-2);
|
||||
width: 100%;
|
||||
padding: var(--fs-space-3);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fs-text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
.area-review-head:hover { color: var(--fs-text-primary); }
|
||||
.area-review-head:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
|
||||
.area-review-count {
|
||||
background: var(--fs-accent-soft);
|
||||
color: var(--fs-accent);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.05rem 0.45rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.area-review-chev { margin-left: auto; color: var(--fs-text-tertiary); }
|
||||
|
||||
.area-proposals { list-style: none; margin: 0; padding: 0 var(--fs-space-3) var(--fs-space-3); display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
||||
.area-proposal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--fs-space-3);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--fs-space-2) var(--fs-space-3);
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-md);
|
||||
}
|
||||
.area-proposal-text { display: flex; align-items: center; gap: var(--fs-space-2); flex-wrap: wrap; font-size: 0.85rem; min-width: 0; }
|
||||
.area-proposal-name { color: var(--fs-text-primary); }
|
||||
.area-proposal-arrow { color: var(--fs-text-tertiary); }
|
||||
.area-proposal-target { color: var(--fs-accent); }
|
||||
.area-proposal-actions { display: flex; gap: var(--fs-space-2); flex-shrink: 0; }
|
||||
|
||||
/* The two bases must never look alike — one is mechanical, the other is the
|
||||
reviewer's judgment, and that difference is the whole decision. */
|
||||
.area-basis { font-size: 0.68rem; border-radius: var(--fs-radius-sm); padding: 0.05rem 0.4rem; }
|
||||
.area-basis--exact { background: var(--fs-status-done-bg); color: var(--fs-status-done-fg); }
|
||||
.area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium-fg); }
|
||||
|
||||
.area-offer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--fs-space-3);
|
||||
flex-wrap: wrap;
|
||||
padding: var(--fs-space-3);
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-secondary);
|
||||
background: var(--fs-accent-faint);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
|
||||
.area-field { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.area-label { font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
||||
.area-select { box-sizing: border-box; width: 100%; }
|
||||
|
||||
.area-chip {
|
||||
font-size: 0.66rem;
|
||||
color: var(--fs-accent);
|
||||
background: var(--fs-accent-soft);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.05rem 0.45rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Toolbar ──────────────────────────────────────────────────── */
|
||||
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
||||
.btn-add-system {
|
||||
@@ -372,18 +621,9 @@ async function confirmDelete() {
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
|
||||
.system-input, .system-textarea {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--fs-accent); }
|
||||
/* The input itself is the .fs-input canon (components.css); only the
|
||||
layout remainder lives here. */
|
||||
.system-input, .system-textarea { box-sizing: border-box; width: 100%; }
|
||||
.system-textarea { resize: vertical; }
|
||||
|
||||
.system-form-actions { display: flex; gap: 0.4rem; }
|
||||
@@ -439,7 +679,7 @@ async function confirmDelete() {
|
||||
font-weight: 500;
|
||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.45rem;
|
||||
flex-shrink: 0;
|
||||
@@ -449,7 +689,7 @@ async function confirmDelete() {
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--fs-text-tertiary);
|
||||
color: var(--fs-text-tertiary-fg);
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.45rem;
|
||||
@@ -493,10 +733,10 @@ async function confirmDelete() {
|
||||
border: 1px dashed var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
}
|
||||
.empty-title { margin: 0; font-weight: 500; color: var(--fs-text-primary); }
|
||||
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--fs-text-tertiary); max-width: 32ch; }
|
||||
/* remainders over the shared recipes (components.css, m302) */
|
||||
.empty-title { margin: 0; color: var(--fs-text-primary); }
|
||||
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; max-width: 32ch; }
|
||||
|
||||
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
|
||||
|
||||
/* ── Skeleton ─────────────────────────────────────────────────── */
|
||||
@keyframes skel-shine { to { background-position: 200% center; } }
|
||||
|
||||
@@ -168,7 +168,7 @@ function focusInput() {
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
border: 1px solid var(--fs-accent);
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task, TaskStatus } from "@/types/task";
|
||||
import StatusBadge from "@/components/StatusBadge.vue";
|
||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
import { renderPreview } from "@/utils/markdown";
|
||||
|
||||
const props = defineProps<{
|
||||
task: Task;
|
||||
compact?: boolean;
|
||||
projectTitle?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
"tag-click": [tag: string];
|
||||
"status-toggle": [id: number, status: TaskStatus];
|
||||
}>();
|
||||
|
||||
const statusCycle: Record<TaskStatus, TaskStatus> = {
|
||||
todo: "in_progress",
|
||||
in_progress: "done",
|
||||
done: "todo",
|
||||
cancelled: "todo",
|
||||
};
|
||||
|
||||
const statusDotClass: Record<TaskStatus, string> = {
|
||||
todo: "dot-todo",
|
||||
in_progress: "dot-in-progress",
|
||||
done: "dot-done",
|
||||
cancelled: "dot-cancelled",
|
||||
};
|
||||
|
||||
const statusTitle: Record<TaskStatus, string> = {
|
||||
todo: "Todo — click to mark In Progress",
|
||||
in_progress: "In Progress — click to mark Done",
|
||||
done: "Done — click to mark Todo",
|
||||
cancelled: "Cancelled — click to mark Todo",
|
||||
};
|
||||
|
||||
function cycleStatus() {
|
||||
emit("status-toggle", props.task.id, statusCycle[props.task.status!]);
|
||||
}
|
||||
|
||||
|
||||
function isOverdue(): boolean {
|
||||
if (!props.task.due_date || props.task.status === "done") return false;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return props.task.due_date < today;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link :to="`/tasks/${task.id}`" :class="['task-card', { compact }]">
|
||||
|
||||
<!-- Compact: single row -->
|
||||
<template v-if="compact">
|
||||
<button
|
||||
:class="['status-dot', statusDotClass[task.status!]]"
|
||||
:title="statusTitle[task.status!]"
|
||||
@click.prevent.stop="cycleStatus"
|
||||
></button>
|
||||
<PriorityBadge :priority="task.priority!" />
|
||||
<span class="task-title-compact">{{ task.title || "Untitled" }}</span>
|
||||
<span v-if="projectTitle" class="project-crumb">{{ projectTitle }}</span>
|
||||
<div class="task-tags-compact">
|
||||
<TagPill
|
||||
v-for="tag in task.tags?.slice(0, 2)"
|
||||
:key="tag"
|
||||
:tag="tag"
|
||||
@click.stop="emit('tag-click', tag)"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="task.due_date" :class="['due-compact', { overdue: isOverdue() }]">
|
||||
{{ task.due_date }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Full: original layout -->
|
||||
<template v-else>
|
||||
<div class="task-top">
|
||||
<StatusBadge
|
||||
:status="task.status!"
|
||||
clickable
|
||||
@click.prevent.stop="cycleStatus"
|
||||
/>
|
||||
<PriorityBadge :priority="task.priority!" />
|
||||
<h3 class="task-title">{{ task.title || "Untitled" }}</h3>
|
||||
</div>
|
||||
<div v-if="task.body" class="task-preview prose" v-html="renderPreview(task.body)"></div>
|
||||
<div class="task-meta">
|
||||
<span v-if="task.due_date" :class="['due-date', { overdue: isOverdue() }]">
|
||||
Due: {{ task.due_date }}
|
||||
</span>
|
||||
<TagPill
|
||||
v-for="tag in task.tags"
|
||||
:key="tag"
|
||||
:tag="tag"
|
||||
@click.stop="emit('tag-click', tag)"
|
||||
/>
|
||||
<span class="timestamp">{{ relativeTime(task.updated_at) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.task-card {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
border-radius: var(--fs-radius-lg);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--fs-surface-raised);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 6%, transparent);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.task-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--fs-accent) 14.0%, transparent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Compact single-row layout */
|
||||
.task-card.compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
}
|
||||
|
||||
/* Status dot */
|
||||
.status-dot {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: transform 0.1s, opacity 0.1s;
|
||||
}
|
||||
.status-dot:hover {
|
||||
transform: scale(1.25);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.dot-todo {
|
||||
background: var(--fs-status-todo);
|
||||
border: 2px solid var(--fs-status-todo);
|
||||
background: transparent;
|
||||
border: 2px solid var(--fs-text-tertiary);
|
||||
}
|
||||
.dot-in-progress {
|
||||
background: var(--fs-status-in-progress);
|
||||
}
|
||||
.dot-done {
|
||||
background: var(--fs-status-done);
|
||||
}
|
||||
.dot-cancelled {
|
||||
background: var(--fs-status-cancelled);
|
||||
}
|
||||
|
||||
.task-title-compact {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.project-crumb {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.1rem 0.4rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-tags-compact {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.due-compact {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.due-compact.overdue {
|
||||
color: var(--fs-error);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Full layout */
|
||||
.task-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.task-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.task-preview {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--fs-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
max-height: 7.5em;
|
||||
overflow: hidden;
|
||||
}
|
||||
.task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.due-date {
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.due-date.overdue {
|
||||
color: var(--fs-overdue);
|
||||
font-weight: 600;
|
||||
}
|
||||
.timestamp {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* "N/M used" on a list row — surfaced vs opened, for any record kind.
|
||||
*
|
||||
* Extracted from SnippetListView when the rule list needed the same chip
|
||||
* (milestone 333 step 5). The counts read identically for both; what differs
|
||||
* is the ADVICE, which is why that is a prop. A snippet surfaced repeatedly
|
||||
* and never opened should probably go; a rule in the same position may simply
|
||||
* have a `when_to_apply` that fires on the wrong thing, and telling an
|
||||
* operator to delete it would be the wrong nudge half the time.
|
||||
*/
|
||||
import type { RecordUsage } from "@/types/usage";
|
||||
|
||||
const props = defineProps<{
|
||||
usage?: RecordUsage | null;
|
||||
/** What to suggest when this record looks like dead weight. Appended to the
|
||||
* tooltip; kind-specific, because the remedies are. */
|
||||
deadWeightAdvice: string;
|
||||
/** What the record is called in the tooltip's own sentence. */
|
||||
noun?: string;
|
||||
}>();
|
||||
|
||||
/** Offered repeatedly and never opened. Three rather than one because one or
|
||||
* two surfacings is noise — the record may simply not have come up in a
|
||||
* relevant context yet. */
|
||||
const isDeadWeight = () =>
|
||||
!!props.usage && props.usage.pull_count === 0 && props.usage.surfaced_count >= 3;
|
||||
|
||||
/** "" renders nothing. A record nobody has surfaced yet gets no badge at all:
|
||||
* "0/0" would read as a verdict when it is an absence of evidence — and on a
|
||||
* freshly-migrated install that is every row. */
|
||||
const label = () => {
|
||||
const u = props.usage;
|
||||
if (!u || u.surfaced_count === 0) return "";
|
||||
return `${u.pull_count}/${u.surfaced_count} used`;
|
||||
};
|
||||
|
||||
const title = () => {
|
||||
const u = props.usage;
|
||||
if (!u) return "";
|
||||
const last = u.last_pulled_at
|
||||
? `Last opened ${new Date(u.last_pulled_at).toLocaleDateString()}.`
|
||||
: "Never opened.";
|
||||
const verdict = isDeadWeight() ? ` ${props.deadWeightAdvice}` : "";
|
||||
return (
|
||||
`Surfaced to an agent ${u.surfaced_count}×, opened in full ` +
|
||||
`${u.pull_count}×. ${last}${verdict}`
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="label()"
|
||||
class="usage-tag"
|
||||
:class="{ 'usage-dead': isDeadWeight() }"
|
||||
:title="title()"
|
||||
>{{ label() }}</span>
|
||||
</template>
|
||||
|
||||
<!-- The look lives in components.css (canon). Nothing scoped here on purpose:
|
||||
a view that needs different spacing keeps that as its own remainder. -->
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, computed } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import type { DiffLine } from "@/composables/useAssist";
|
||||
import { computeDiff, type DiffLine } from "@/utils/diff";
|
||||
|
||||
interface NoteVersion {
|
||||
id: number;
|
||||
@@ -31,25 +31,7 @@ const loadingDetail = ref(false);
|
||||
|
||||
const diff = computed<DiffLine[]>(() => {
|
||||
if (!selectedVersion.value?.body) return [];
|
||||
const aLines = props.currentBody.split("\n");
|
||||
const bLines = selectedVersion.value.body.split("\n");
|
||||
const m = aLines.length, n = bLines.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = m - 1; i >= 0; i--)
|
||||
for (let j = n - 1; j >= 0; j--)
|
||||
dp[i][j] = aLines[i] === bLines[j]
|
||||
? dp[i + 1][j + 1] + 1
|
||||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
const result: DiffLine[] = [];
|
||||
let i = 0, j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (aLines[i] === bLines[j]) { result.push({ type: "equal", text: aLines[i++] }); j++; }
|
||||
else if (dp[i + 1][j] >= dp[i][j + 1]) result.push({ type: "delete", text: aLines[i++] });
|
||||
else result.push({ type: "insert", text: bLines[j++] });
|
||||
}
|
||||
while (i < m) result.push({ type: "delete", text: aLines[i++] });
|
||||
while (j < n) result.push({ type: "insert", text: bLines[j++] });
|
||||
return result;
|
||||
return computeDiff(props.currentBody, selectedVersion.value.body);
|
||||
});
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
|
||||
@@ -471,7 +471,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
.rail-search-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
@@ -549,7 +548,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
|
||||
.note-tag-pill {
|
||||
font-size: 0.58rem;
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.3rem;
|
||||
@@ -575,8 +574,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note-row:hover .btn-delete { opacity: 1; }
|
||||
|
||||
/* Editor UI */
|
||||
.panel-header {
|
||||
display: flex;
|
||||
@@ -624,8 +621,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
}
|
||||
.tag-row > :first-child { flex: 1; min-width: 0; }
|
||||
|
||||
.btn-suggest-tags { flex-shrink: 0; align-self: center; }
|
||||
|
||||
.tag-suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -650,10 +645,9 @@ defineExpose({ reload: loadProjectNotes });
|
||||
.btn-tag-suggestion.applied {
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
border-color: var(--fs-accent);
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
}
|
||||
|
||||
|
||||
.link-suggest-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { RouterLink } from "vue-router";
|
||||
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||
import KindBadge from "@/components/KindBadge.vue";
|
||||
import type { TaskKind } from "@/types/note";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
||||
@@ -28,6 +30,7 @@ interface Task {
|
||||
due_date: string | null;
|
||||
updated_at: string;
|
||||
body?: string;
|
||||
task_kind?: TaskKind;
|
||||
}
|
||||
|
||||
const tasks = ref<Task[]>([]);
|
||||
@@ -242,6 +245,7 @@ defineExpose({ reload: loadAll });
|
||||
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '○' }}</button>
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
||||
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
||||
</li>
|
||||
@@ -267,6 +271,7 @@ defineExpose({ reload: loadAll });
|
||||
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '○' }}</button>
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
||||
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
||||
</li>
|
||||
@@ -281,7 +286,7 @@ defineExpose({ reload: loadAll });
|
||||
<div v-if="activeTask" class="task-detail">
|
||||
<div class="detail-header">
|
||||
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-text btn-edit-task" title="Open full editor">Edit ↗</RouterLink>
|
||||
<span :class="['status-badge', `status-${activeTask.status}`]" @click="cycleStatus(activeTask, $event)" title="Click to cycle status">
|
||||
<span :class="['status-cycler', `status-${activeTask.status}`]" @click="cycleStatus(activeTask, $event)" title="Click to cycle status">
|
||||
{{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }}
|
||||
</span>
|
||||
<template v-if="deleteConfirmPending">
|
||||
@@ -419,8 +424,8 @@ defineExpose({ reload: loadAll });
|
||||
border-radius: 10px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent); }
|
||||
.ms-status-completed { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success); }
|
||||
.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent-fg); }
|
||||
.ms-status-completed { background: color-mix(in srgb, var(--fs-success) 15%, transparent); color: var(--fs-success-fg); }
|
||||
|
||||
.task-items {
|
||||
list-style: none;
|
||||
@@ -496,7 +501,10 @@ defineExpose({ reload: loadAll });
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
/* An interactive CYCLER, not a chip: it is clickable, outlined and
|
||||
transparent. It shared a name with the task chip and was never the same
|
||||
shape (#3132). */
|
||||
.status-cycler {
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
@@ -508,8 +516,8 @@ defineExpose({ reload: loadAll });
|
||||
user-select: none;
|
||||
margin-left: auto;
|
||||
}
|
||||
.status-badge.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
|
||||
.status-badge.status-done { border-color: var(--fs-success); color: var(--fs-success); background: color-mix(in srgb, var(--fs-success) 10%, transparent); }
|
||||
.status-cycler.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
|
||||
.status-cycler.status-done { border-color: var(--fs-success); color: var(--fs-success-fg); background: color-mix(in srgb, var(--fs-success) 10%, transparent); }
|
||||
|
||||
.btn-edit-task { margin-left: 0.25rem; }
|
||||
.btn-edit-task:hover { text-decoration: underline; }
|
||||
|
||||
@@ -24,10 +24,16 @@ const allRulebooks = ref<Rulebook[]>([]);
|
||||
const showPicker = ref(false);
|
||||
const expandedRuleIds = ref<Set<number>>(new Set());
|
||||
|
||||
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
|
||||
const ruleDetails = ref<Record<number, {
|
||||
why: string; how_to_apply: string;
|
||||
verify_with: string; expires_when: string; verified_at: string | null;
|
||||
}>>({});
|
||||
|
||||
const showProjectRuleForm = ref(false);
|
||||
const newProjectRule = ref({ title: "", statement: "", why: "", how_to_apply: "" });
|
||||
const newProjectRule = ref({
|
||||
title: "", statement: "", why: "", how_to_apply: "",
|
||||
when_to_apply: "", tier: "always_on" as "always_on" | "conditional",
|
||||
});
|
||||
|
||||
async function load() {
|
||||
applicable.value = await getProjectApplicableRules(props.projectId);
|
||||
@@ -64,6 +70,9 @@ async function toggleRuleExpand(ruleId: number) {
|
||||
ruleDetails.value[ruleId] = {
|
||||
why: rule.why || "",
|
||||
how_to_apply: rule.how_to_apply || "",
|
||||
verify_with: rule.verify_with || "",
|
||||
expires_when: rule.expires_when || "",
|
||||
verified_at: rule.verified_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -71,6 +80,11 @@ async function toggleRuleExpand(ruleId: number) {
|
||||
expandedRuleIds.value = new Set(expandedRuleIds.value);
|
||||
}
|
||||
|
||||
/** "never run" reads as a stronger claim than an absent date — and it is. */
|
||||
function checkAge(verifiedAt: string | null): string {
|
||||
return verifiedAt ? `last passed ${verifiedAt.slice(0, 10)}` : "never run";
|
||||
}
|
||||
|
||||
function openInRulesView(rulebookId: number, ruleId?: number) {
|
||||
const query: Record<string, string> = { rb: String(rulebookId) };
|
||||
if (ruleId) query.rule = String(ruleId);
|
||||
@@ -90,14 +104,20 @@ interface RulebookGroup {
|
||||
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
|
||||
const byRulebook = new Map<number, RulebookGroup>();
|
||||
for (const r of rules) {
|
||||
// A rule carries topic_id XOR project_id. Only rulebook-scoped rules reach
|
||||
// this list, so a null topic would be a server-side contradiction — skip
|
||||
// it rather than widen the group's type to accommodate a case that means
|
||||
// something is wrong upstream.
|
||||
if (r.topic_id === null) continue;
|
||||
const topicId = r.topic_id;
|
||||
let rb = byRulebook.get(r.rulebook_id);
|
||||
if (!rb) {
|
||||
rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] };
|
||||
byRulebook.set(r.rulebook_id, rb);
|
||||
}
|
||||
let topic = rb.topics.find((t) => t.topic_id === r.topic_id);
|
||||
let topic = rb.topics.find((t) => t.topic_id === topicId);
|
||||
if (!topic) {
|
||||
topic = { topic_id: r.topic_id, topic_title: r.topic_title, rules: [] };
|
||||
topic = { topic_id: topicId, topic_title: r.topic_title, rules: [] };
|
||||
rb.topics.push(topic);
|
||||
}
|
||||
topic.rules.push(r);
|
||||
@@ -113,8 +133,13 @@ async function submitProjectRule() {
|
||||
title: newProjectRule.value.title.trim() || undefined,
|
||||
why: newProjectRule.value.why.trim() || undefined,
|
||||
how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined,
|
||||
when_to_apply: newProjectRule.value.when_to_apply.trim() || undefined,
|
||||
tier: newProjectRule.value.tier,
|
||||
});
|
||||
newProjectRule.value = { title: "", statement: "", why: "", how_to_apply: "" };
|
||||
newProjectRule.value = {
|
||||
title: "", statement: "", why: "", how_to_apply: "",
|
||||
when_to_apply: "", tier: "always_on",
|
||||
};
|
||||
showProjectRuleForm.value = false;
|
||||
await load();
|
||||
}
|
||||
@@ -219,6 +244,24 @@ watch(() => props.projectId, load);
|
||||
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<textarea
|
||||
v-model="newProjectRule.when_to_apply"
|
||||
placeholder="When to apply — the trigger, not the instruction"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="tier-row">
|
||||
<label>
|
||||
<input v-model="newProjectRule.tier" type="radio" value="always_on" />
|
||||
Always on
|
||||
</label>
|
||||
<label>
|
||||
<input v-model="newProjectRule.tier" type="radio" value="conditional" />
|
||||
Conditional
|
||||
</label>
|
||||
<span class="tier-hint">
|
||||
Conditional if you had to name a system, an artifact or a moment to state the trigger.
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="newProjectRule.why"
|
||||
placeholder="Why (optional) — the rationale"
|
||||
@@ -247,6 +290,16 @@ watch(() => props.projectId, load);
|
||||
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||
</div>
|
||||
<!-- Shown only when the rule carries a check. Read-only here: this
|
||||
tab is the project's view of what binds it, and editing a rule
|
||||
belongs on the rulebook surface that owns it. -->
|
||||
<div v-if="ruleDetails[r.id].verify_with">
|
||||
<strong>Check:</strong> {{ ruleDetails[r.id].verify_with }}
|
||||
<span class="rule-check-age">{{ checkAge(ruleDetails[r.id].verified_at) }}</span>
|
||||
</div>
|
||||
<div v-if="ruleDetails[r.id].expires_when">
|
||||
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
|
||||
</div>
|
||||
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
@@ -300,6 +353,13 @@ watch(() => props.projectId, load);
|
||||
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||
</div>
|
||||
<div v-if="ruleDetails[r.id].verify_with">
|
||||
<strong>Check:</strong> {{ ruleDetails[r.id].verify_with }}
|
||||
<span class="rule-check-age">{{ checkAge(ruleDetails[r.id].verified_at) }}</span>
|
||||
</div>
|
||||
<div v-if="ruleDetails[r.id].expires_when">
|
||||
<strong>Ends when:</strong> {{ ruleDetails[r.id].expires_when }}
|
||||
</div>
|
||||
<button
|
||||
class="edit-link"
|
||||
@click="openInRulesView(r.rulebook_id, r.id)"
|
||||
@@ -345,6 +405,11 @@ watch(() => props.projectId, load);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tier-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; font-size: 0.85rem; }
|
||||
.tier-row label { display: inline-flex; align-items: center; gap: 0.3rem; }
|
||||
.tier-row input { accent-color: var(--fs-accent); }
|
||||
.tier-hint { flex: 1; min-width: 12rem; font-size: 0.75rem; color: var(--fs-text-tertiary); }
|
||||
|
||||
.excluded-note { margin: 0 0 0.5rem; color: var(--fs-text-tertiary); font-size: 0.85rem; }
|
||||
.chip-excluded { opacity: 0.8; text-decoration: line-through; }
|
||||
.chip-excluded .chip-remove { text-decoration: none; }
|
||||
@@ -391,6 +456,11 @@ ul { list-style: none; padding: 0; margin: 0; }
|
||||
}
|
||||
.rule-head { cursor: pointer; }
|
||||
.rule-title { font-weight: 500; }
|
||||
.rule-check-age {
|
||||
margin-left: var(--fs-space-2);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
|
||||
.rule-detail {
|
||||
margin-top: 0.5rem; padding: 0.5rem;
|
||||
|
||||
@@ -1,18 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import { computed, ref, watch, onMounted } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||
import type { RuleTier } from "@/api/rulebooks";
|
||||
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
||||
|
||||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const canon = useCanonicalSystemsStore();
|
||||
const title = ref("");
|
||||
const statement = ref("");
|
||||
const whenToApply = ref("");
|
||||
const tier = ref<RuleTier>("always_on");
|
||||
const systemIds = ref<number[]>([]);
|
||||
const why = ref("");
|
||||
const howToApply = ref("");
|
||||
const verifyWith = ref("");
|
||||
const expiresWhen = ref("");
|
||||
|
||||
const relations = computed(() => store.currentRule?.relations ?? []);
|
||||
|
||||
// The label a reader needs to judge an edge, not the stored token.
|
||||
const RELATION_LABEL: Record<string, { outgoing: string; incoming: string }> = {
|
||||
co_surfaces: { outgoing: "arrives with", incoming: "arrives with" },
|
||||
overrides: { outgoing: "overrides", incoming: "is overridden by" },
|
||||
elaborates: { outgoing: "elaborates", incoming: "is elaborated by" },
|
||||
};
|
||||
|
||||
function relationLabel(kind: string, direction: "outgoing" | "incoming") {
|
||||
return RELATION_LABEL[kind]?.[direction] ?? kind;
|
||||
}
|
||||
|
||||
function toggleSystem(id: number) {
|
||||
const at = systemIds.value.indexOf(id);
|
||||
if (at >= 0) systemIds.value.splice(at, 1);
|
||||
else systemIds.value.push(id);
|
||||
}
|
||||
|
||||
const isCreating = ref(props.ruleId === null);
|
||||
|
||||
// The stored stamp, not the draft: it describes the check that was RUN, and
|
||||
// an unsaved edit to the textarea has not been run against anything.
|
||||
const verifiedAt = computed(() => store.currentRule?.verified_at ?? null);
|
||||
const savedCheck = computed(() => store.currentRule?.verify_with ?? "");
|
||||
// Built here rather than in the template: same shape as the server's
|
||||
// last_verified_label, and it keeps the null-narrowing in TypeScript's reach.
|
||||
const stampLabel = computed(() =>
|
||||
verifiedAt.value ? `Last checked ${verifiedAt.value.slice(0, 10)}` : "Never checked",
|
||||
);
|
||||
const verifying = ref(false);
|
||||
|
||||
async function verify(stillTrue: boolean) {
|
||||
if (props.ruleId === null) return;
|
||||
verifying.value = true;
|
||||
try {
|
||||
await store.verifyRule(props.ruleId, stillTrue);
|
||||
} finally {
|
||||
verifying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (props.ruleId !== null) {
|
||||
await store.fetchRule(props.ruleId);
|
||||
@@ -20,15 +69,26 @@ async function load() {
|
||||
if (r) {
|
||||
title.value = r.title;
|
||||
statement.value = r.statement;
|
||||
whenToApply.value = r.when_to_apply || "";
|
||||
tier.value = r.tier || "always_on";
|
||||
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
||||
why.value = r.why || "";
|
||||
howToApply.value = r.how_to_apply || "";
|
||||
verifyWith.value = r.verify_with || "";
|
||||
expiresWhen.value = r.expires_when || "";
|
||||
}
|
||||
} else {
|
||||
title.value = "";
|
||||
statement.value = "";
|
||||
whenToApply.value = "";
|
||||
tier.value = "always_on";
|
||||
systemIds.value = [];
|
||||
why.value = "";
|
||||
howToApply.value = "";
|
||||
verifyWith.value = "";
|
||||
expiresWhen.value = "";
|
||||
}
|
||||
await canon.fetchCatalog();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
@@ -36,16 +96,26 @@ async function save() {
|
||||
emit("close");
|
||||
return;
|
||||
}
|
||||
const fields = {
|
||||
title: title.value,
|
||||
statement: statement.value,
|
||||
when_to_apply: whenToApply.value,
|
||||
tier: tier.value,
|
||||
// Always sent, so clearing the last area actually clears it — the server
|
||||
// reads a list as "these ARE the areas now".
|
||||
system_ids: systemIds.value,
|
||||
why: why.value,
|
||||
how_to_apply: howToApply.value,
|
||||
// Always sent, including empty. The REST door maps "" to NULL, so
|
||||
// clearing a field here actually clears it — the MCP door's "" means
|
||||
// "leave unchanged" and needs an explicit clear_fields list instead.
|
||||
verify_with: verifyWith.value,
|
||||
expires_when: expiresWhen.value,
|
||||
};
|
||||
if (isCreating.value && props.topicId !== null) {
|
||||
await store.createRule(props.topicId, {
|
||||
title: title.value, statement: statement.value,
|
||||
why: why.value, how_to_apply: howToApply.value,
|
||||
});
|
||||
await store.createRule(props.topicId, fields);
|
||||
} else if (props.ruleId !== null) {
|
||||
await store.updateRule(props.ruleId, {
|
||||
title: title.value, statement: statement.value,
|
||||
why: why.value, how_to_apply: howToApply.value,
|
||||
});
|
||||
await store.updateRule(props.ruleId, fields);
|
||||
}
|
||||
emit("close");
|
||||
}
|
||||
@@ -77,6 +147,108 @@ watch(() => props.ruleId, load);
|
||||
Statement <span class="required">*</span>
|
||||
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
||||
</label>
|
||||
<label>
|
||||
When to apply
|
||||
<textarea
|
||||
v-model="whenToApply"
|
||||
rows="2"
|
||||
placeholder="The trigger, not the instruction — “before any git push”, “when a release is being cut”."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<fieldset class="tier">
|
||||
<legend>How it reaches a session</legend>
|
||||
<label class="tier-opt">
|
||||
<input v-model="tier" type="radio" value="always_on" />
|
||||
<span>
|
||||
<strong>Always on</strong>
|
||||
— loaded into every session.
|
||||
</span>
|
||||
</label>
|
||||
<label class="tier-opt">
|
||||
<input v-model="tier" type="radio" value="conditional" />
|
||||
<span>
|
||||
<strong>Conditional</strong>
|
||||
— arrives when its trigger fires.
|
||||
</span>
|
||||
</label>
|
||||
<p class="tier-test">
|
||||
The test: can you name the trigger <em>without</em> naming a system, an artifact type
|
||||
or a moment? If the honest answer is “whenever you are working”, it is always on.
|
||||
Conditional costs nothing when it is irrelevant, which is what lets it be as long as
|
||||
it needs to be.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset v-if="canon.catalog.length" class="areas">
|
||||
<legend>Areas this rule is about</legend>
|
||||
<label v-for="entry in canon.catalog" :key="entry.id" class="area-opt">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="systemIds.includes(entry.id)"
|
||||
@change="toggleSystem(entry.id)"
|
||||
/>
|
||||
<span>{{ entry.name }}</span>
|
||||
</label>
|
||||
<p class="tier-test">
|
||||
What lets this rule reach a project working in that area.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="check">
|
||||
<legend>Can this rule go stale?</legend>
|
||||
<p class="tier-test intro">
|
||||
Most rules are <em>decisions</em> — they have no truth value and change only when you
|
||||
change them. Leave this empty for those. Fill it in when the rule asserts a
|
||||
<em>fact</em> about something outside your control, because those go false quietly.
|
||||
</p>
|
||||
<label>
|
||||
How to check it is still true
|
||||
<textarea
|
||||
v-model="verifyWith"
|
||||
rows="2"
|
||||
placeholder="A command, a path, a query — something runnable beats prose."
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
What would end it
|
||||
<textarea
|
||||
v-model="expiresWhen"
|
||||
rows="2"
|
||||
placeholder="A state, not a date — “when the runner can be given a bash shell”."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-if="savedCheck" class="stamp">
|
||||
<span class="stamp-age" :class="{ unchecked: !verifiedAt }">{{ stampLabel }}</span>
|
||||
<span class="stamp-actions">
|
||||
<button type="button" :disabled="verifying" @click="verify(true)">Still true</button>
|
||||
<button type="button" :disabled="verifying" @click="verify(false)">No longer true</button>
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="savedCheck" class="tier-test">
|
||||
Record this after actually running the check, never on the strength of the rule
|
||||
sounding plausible. “No longer true” deliberately stores nothing — the rule is wrong,
|
||||
not in a state worth recording, so it stays at the top of the sweep until you fix or
|
||||
retire it.
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<section v-if="relations.length" class="relations">
|
||||
<h3>Related rules</h3>
|
||||
<ul>
|
||||
<li v-for="rel in relations" :key="rel.id" class="relation">
|
||||
<span class="relation-kind">{{ relationLabel(rel.kind, rel.direction) }}</span>
|
||||
<span class="relation-target">rule #{{ rel.rule_id }}</span>
|
||||
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="tier-test">
|
||||
Rules that <em>fail together</em> are linked, never merged — a merged rule cannot be
|
||||
cited, surfaced or suppressed a clause at a time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<label>
|
||||
Why
|
||||
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
|
||||
@@ -85,6 +257,17 @@ watch(() => props.ruleId, load);
|
||||
How to apply
|
||||
<textarea v-model="howToApply" rows="4" placeholder="When / where this kicks in." />
|
||||
</label>
|
||||
|
||||
<!-- Only on an existing rule: a rule being created has no past, and an
|
||||
"Edit history — none" line on a blank form reads as a broken panel.
|
||||
Keyed on ruleId so switching rules reloads rather than showing the
|
||||
previous rule's history under the new one's text. -->
|
||||
<RuleHistoryPanel
|
||||
v-if="!isCreating && ruleId !== null"
|
||||
:key="ruleId"
|
||||
:rule-id="ruleId"
|
||||
:current="store.currentRule"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
@@ -118,6 +301,48 @@ input, textarea {
|
||||
padding: 0.5rem; font: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
fieldset { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: 0.75rem; margin-bottom: 1rem; }
|
||||
legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||||
.tier-opt, .area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
|
||||
.tier-opt input, .area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||||
.tier-test { margin: 0.5rem 0 0; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
||||
|
||||
.relations h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||||
.relations ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.relation { display: flex; align-items: baseline; gap: 0.4rem; flex-wrap: wrap; font-size: 0.85rem; }
|
||||
.relation-kind { color: var(--fs-accent); }
|
||||
.relation-target { color: var(--fs-text-primary); }
|
||||
.relation-note { width: 100%; font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
||||
|
||||
/* A real base rule, not just descendants: the dangling-style check reads a
|
||||
class that only ever appears as an ancestor as a half-deleted rule, and it
|
||||
is right to — an element whose appearance comes only from its tag is one
|
||||
`fieldset {}` edit away from being unstyled. */
|
||||
.check { margin-bottom: 1rem; }
|
||||
.check .intro { margin-top: 0; margin-bottom: 0.75rem; }
|
||||
.check label { margin-bottom: 0.75rem; }
|
||||
.stamp {
|
||||
display: flex; align-items: center; gap: var(--fs-space-2);
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.stamp-age { font-size: 0.8rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
|
||||
/* Never-checked is INFORMATION, not an error: it is the ordinary starting
|
||||
state of every constraint anyone has just written. --fs-overdue (error red)
|
||||
is reserved for a broken promise like a missed due date; a verification age
|
||||
is not one, and colouring it that way would make a brand-new rule look
|
||||
broken. Secondary text, weighted normally. */
|
||||
.stamp-age.unchecked { color: var(--fs-text-tertiary); font-style: italic; }
|
||||
.stamp-actions { display: flex; gap: var(--fs-space-2); margin-left: auto; }
|
||||
.stamp-actions button {
|
||||
cursor: pointer; font: inherit; font-size: 0.78rem;
|
||||
background: var(--fs-surface-raised); color: var(--fs-text-primary);
|
||||
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-sm);
|
||||
padding: 0.2rem 0.55rem;
|
||||
}
|
||||
.stamp-actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
|
||||
.stamp-actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
|
||||
|
||||
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
||||
.trash:hover, .close:hover { opacity: 1; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* What a rule USED TO SAY — inside the slide-over, where a rule is read in
|
||||
* full. Not on the list row: a history entry point there would compete with
|
||||
* the row's actual job.
|
||||
*
|
||||
* A SIBLING OF HistoryPanel.vue, NOT A REUSE OF IT, and the reason is in its
|
||||
* props: `noteId` + `currentBody`, a `NoteVersion` carrying tags and pin
|
||||
* columns, a fetch of /api/notes/…, a `restore` emit, and pin/unpin buttons.
|
||||
* Every one of those is note-shaped. Rules have no tags, no pins, and
|
||||
* deliberately no restore, and a rule's text is EIGHT fields rather than one
|
||||
* body — which changes the central question from "what changed" to "which
|
||||
* fields moved".
|
||||
*
|
||||
* What was genuinely shared is shared: DiffView.vue takes DiffLine[] and
|
||||
* nothing note-shaped, and the LCS walk now lives in utils/diff.ts, which
|
||||
* this file uses rather than copying a fourth time (#3207).
|
||||
*/
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import { computeDiff } from "@/utils/diff";
|
||||
import {
|
||||
listRuleVersions, getRuleVersion, type Rule, type RuleVersion,
|
||||
} from "@/api/rulebooks";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const props = defineProps<{ ruleId: number; current: Rule | null }>();
|
||||
|
||||
const toast = useToastStore();
|
||||
const versions = ref<RuleVersion[]>([]);
|
||||
const selected = ref<RuleVersion | null>(null);
|
||||
const expanded = ref(false);
|
||||
const loading = ref(false);
|
||||
const loadingDetail = ref(false);
|
||||
|
||||
// The eight TEXT fields a version carries, in the order the editor shows
|
||||
// them. Narrowed to its own type rather than `keyof RuleVersion`, which would
|
||||
// also admit id/rule_id/user_id/created_at — none of which is text a reader
|
||||
// compares, and all of which would widen every lookup below to `number`.
|
||||
// Labels rather than column names: a reader is deciding whether to open a
|
||||
// row, and "How to apply" reads where "how_to_apply" has to be decoded.
|
||||
type TextField =
|
||||
| "title" | "statement" | "when_to_apply" | "tier"
|
||||
| "why" | "how_to_apply" | "verify_with" | "expires_when";
|
||||
|
||||
const FIELDS: Array<[TextField, string]> = [
|
||||
["title", "Title"],
|
||||
["statement", "Statement"],
|
||||
["when_to_apply", "When to apply"],
|
||||
["tier", "Tier"],
|
||||
["why", "Why"],
|
||||
["how_to_apply", "How to apply"],
|
||||
["verify_with", "Check"],
|
||||
["expires_when", "Ends when"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Which fields this edit moved.
|
||||
*
|
||||
* A version holds the text the edit REPLACED, so the edit is the step from
|
||||
* this row to the NEXT NEWER state — the version above it in the list, or,
|
||||
* for the newest row, the rule as it stands now. Comparing against the row
|
||||
* below instead would attribute every change to the wrong edit.
|
||||
*/
|
||||
function changedFields(index: number): string[] {
|
||||
const before = versions.value[index];
|
||||
// `Rule` carries all eight as required strings; a RuleVersion carries them
|
||||
// only once opened, which is what the undefined check below is about.
|
||||
const after: Pick<Rule, TextField> | RuleVersion | null =
|
||||
index === 0 ? props.current : versions.value[index - 1] ?? null;
|
||||
if (!before || !after) return [];
|
||||
return FIELDS
|
||||
.filter(([key]) => {
|
||||
// A listing row carries only the title; the rest arrive when opened.
|
||||
// Undefined means NOT LOADED, which is not the same as unchanged — so a
|
||||
// field nobody has fetched is claimed as neither.
|
||||
const a = before[key];
|
||||
const b = after[key];
|
||||
if (a === undefined || b === undefined) return false;
|
||||
return (a ?? "") !== (b ?? "");
|
||||
})
|
||||
.map(([, label]) => label);
|
||||
}
|
||||
|
||||
/** True when this edit rewrote or removed the rule's check.
|
||||
*
|
||||
* Worth its own marker because editing `verify_with` silently drops
|
||||
* `verified_at` (milestone 312) — the moment a rule re-entered the staleness
|
||||
* sweep. That happens nowhere a reader can see it, and this row is the only
|
||||
* surface that can say when it happened. */
|
||||
function checkChanged(index: number): boolean {
|
||||
return changedFields(index).includes("Check");
|
||||
}
|
||||
|
||||
const diff = computed(() => {
|
||||
if (!selected.value || selected.value.statement === undefined) return [];
|
||||
const now = props.current?.statement ?? "";
|
||||
return computeDiff(now, selected.value.statement);
|
||||
});
|
||||
|
||||
function stamp(iso: string): string {
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
versions.value = await listRuleVersions(props.ruleId);
|
||||
} catch {
|
||||
toast.show("Could not load this rule's history", "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function open(v: RuleVersion) {
|
||||
if (selected.value?.id === v.id) {
|
||||
selected.value = null;
|
||||
return;
|
||||
}
|
||||
loadingDetail.value = true;
|
||||
try {
|
||||
const full = await getRuleVersion(props.ruleId, v.id);
|
||||
// Merged back into the list so `changedFields` can compare against real
|
||||
// text once a neighbour has been opened, instead of staying blind.
|
||||
const at = versions.value.findIndex((x) => x.id === v.id);
|
||||
if (at >= 0) versions.value[at] = { ...versions.value[at], ...full };
|
||||
selected.value = versions.value[at] ?? full;
|
||||
} catch {
|
||||
toast.show("Could not open that version", "error");
|
||||
} finally {
|
||||
loadingDetail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(() => props.ruleId, () => { selected.value = null; load(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="history">
|
||||
<button class="toggle" :aria-expanded="expanded" @click="expanded = !expanded">
|
||||
<span>Edit history</span>
|
||||
<span class="count">{{ versions.length || "none" }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="expanded" class="body">
|
||||
<p v-if="loading" class="state">Loading…</p>
|
||||
|
||||
<!-- Never reworded is the ordinary case, and must not read as a fault. -->
|
||||
<p v-else-if="!versions.length" class="state empty">
|
||||
This rule has never been reworded. Nothing was recorded before the history
|
||||
existed, so an older rule starts empty too.
|
||||
</p>
|
||||
|
||||
<template v-else>
|
||||
<p class="lede">
|
||||
Each entry is what the rule said <em>before</em> that edit. The wording it
|
||||
was changed to is the rule as it stands above.
|
||||
</p>
|
||||
<ol class="rows">
|
||||
<li v-for="(v, i) in versions" :key="v.id" class="row">
|
||||
<button
|
||||
class="row-head"
|
||||
:class="{ open: selected?.id === v.id }"
|
||||
@click="open(v)"
|
||||
>
|
||||
<span class="when">{{ stamp(v.created_at) }}</span>
|
||||
<span class="fields">
|
||||
{{ changedFields(i).join(", ") || "opened to compare" }}
|
||||
</span>
|
||||
<span v-if="checkChanged(i)" class="check-moved">check reset</span>
|
||||
</button>
|
||||
|
||||
<div v-if="selected?.id === v.id" class="detail">
|
||||
<p v-if="loadingDetail" class="state">Loading…</p>
|
||||
<template v-else>
|
||||
<p v-if="checkChanged(i)" class="warn">
|
||||
This edit changed the rule's check, which cleared its verification
|
||||
stamp — the rule went back to the top of the staleness sweep here.
|
||||
</p>
|
||||
<dl class="fields-list">
|
||||
<template v-for="[key, label] in FIELDS" :key="key">
|
||||
<template v-if="key !== 'statement' && v[key]">
|
||||
<dt>{{ label }}</dt>
|
||||
<dd>{{ v[key] }}</dd>
|
||||
</template>
|
||||
</template>
|
||||
</dl>
|
||||
<h4>Statement</h4>
|
||||
<DiffView v-if="diff.length" :diff="diff" />
|
||||
<p v-else class="state">The statement did not change in this edit.</p>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.history { border-top: 1px solid var(--fs-border-color); padding-top: var(--fs-space-3); }
|
||||
|
||||
.toggle {
|
||||
display: flex; align-items: center; gap: var(--fs-space-2); width: 100%;
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
font: inherit; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary);
|
||||
}
|
||||
.toggle:hover { color: var(--fs-text-primary); }
|
||||
.count {
|
||||
margin-left: auto; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.body { margin-top: var(--fs-space-3); display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
.state { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
|
||||
.state.empty { color: var(--fs-text-tertiary); }
|
||||
.lede {
|
||||
margin: 0; max-width: 62ch; font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary); line-height: var(--fs-leading-body);
|
||||
}
|
||||
|
||||
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
||||
.row { background: var(--fs-surface-raised); border-radius: var(--fs-radius-md); }
|
||||
|
||||
.row-head {
|
||||
display: flex; align-items: baseline; gap: var(--fs-space-3); width: 100%;
|
||||
background: none; border: none; cursor: pointer; text-align: left;
|
||||
padding: var(--fs-space-2) var(--fs-space-3);
|
||||
font: inherit; font-size: var(--fs-size-body-sm); color: var(--fs-text-primary);
|
||||
}
|
||||
.row-head:hover { background: var(--fs-surface-hover); border-radius: var(--fs-radius-md); }
|
||||
.when {
|
||||
font-variant-numeric: tabular-nums; color: var(--fs-text-secondary);
|
||||
font-size: var(--fs-size-tiny);
|
||||
}
|
||||
.fields { color: var(--fs-text-primary); min-width: 0; overflow-wrap: anywhere; }
|
||||
|
||||
/* A TINT, not the solid token. `--fs-warning-fg` is defined as "warning text
|
||||
ON A WARNING TINT" — painting it over solid `--fs-warning` is the same-hue
|
||||
contrast failure #3141 records. The 12% mix is how theme.css builds its own
|
||||
`-bg` pairs, and it keeps the value a resolvable var() rather than a raw hex
|
||||
that check_design_tokens.py cannot see at all. */
|
||||
.check-moved {
|
||||
margin-left: auto; flex: none;
|
||||
background: color-mix(in srgb, var(--fs-warning) 12%, transparent);
|
||||
color: var(--fs-warning-fg);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: var(--fs-size-tiny); letter-spacing: var(--fs-tracking-tiny);
|
||||
}
|
||||
|
||||
.detail {
|
||||
padding: 0 var(--fs-space-3) var(--fs-space-3);
|
||||
display: flex; flex-direction: column; gap: var(--fs-space-2);
|
||||
}
|
||||
.warn {
|
||||
margin: 0; font-size: var(--fs-size-tiny); line-height: var(--fs-leading-body);
|
||||
color: var(--fs-warning-fg);
|
||||
background: color-mix(in srgb, var(--fs-warning) 12%, transparent);
|
||||
border-radius: var(--fs-radius-sm); padding: var(--fs-space-2);
|
||||
}
|
||||
.fields-list { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem var(--fs-space-3); margin: 0; }
|
||||
.fields-list dt {
|
||||
font-size: var(--fs-size-tiny); text-transform: uppercase;
|
||||
letter-spacing: var(--fs-tracking-tiny); color: var(--fs-text-tertiary);
|
||||
}
|
||||
.fields-list dd {
|
||||
margin: 0; font-size: var(--fs-size-body-sm);
|
||||
color: var(--fs-text-primary); min-width: 0; overflow-wrap: anywhere;
|
||||
}
|
||||
h4 { margin: var(--fs-space-2) 0 0; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); }
|
||||
</style>
|
||||
@@ -1,5 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { RuleHeader } from "@/api/rulebooks";
|
||||
import UsageBadge from "@/components/UsageBadge.vue";
|
||||
|
||||
/** The dead-weight nudge for a RULE — two remedies, not one, which is the
|
||||
* whole reason this advice is per-kind. A snippet nobody opens should
|
||||
* probably go. A rule nobody opens may be perfectly good and simply firing on
|
||||
* the wrong thing, so "delete it" would be the wrong nudge half the time and
|
||||
* the operator has to be the one who picks. */
|
||||
const RULE_DEAD_WEIGHT =
|
||||
"Kept arriving without being read. Either its trigger fires on the wrong " +
|
||||
"work — reword “when to apply” so it says when — or it is not wanted here. " +
|
||||
"Until one or the other, it takes a slot in every write it matches.";
|
||||
|
||||
defineProps<{ topicId: number; rules: RuleHeader[] }>();
|
||||
const emit = defineEmits<{
|
||||
@@ -13,17 +24,36 @@ const emit = defineEmits<{
|
||||
<header><h2>Rules</h2></header>
|
||||
<ul>
|
||||
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
||||
<div class="title">{{ r.title }}</div>
|
||||
<div class="title">
|
||||
{{ r.title }}
|
||||
<!-- Only conditional is marked: always-on is the default and
|
||||
badging every row would say nothing. -->
|
||||
<span v-if="r.tier === 'conditional'" class="rule-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
|
||||
<!-- Present only on a rule carrying a check, so the chip's very
|
||||
presence says "this one asserts a fact that can go false". -->
|
||||
<span
|
||||
v-if="r.last_verified"
|
||||
class="rule-chip check-chip"
|
||||
:class="{ unchecked: r.last_verified === 'never' }"
|
||||
:title="r.last_verified === 'never'
|
||||
? 'Asserts a fact nobody has confirmed yet'
|
||||
: `Check last passed ${r.last_verified}`"
|
||||
>{{ r.last_verified === "never" ? "unverified" : `checked ${r.last_verified}` }}</span>
|
||||
<UsageBadge :usage="r.usage" :dead-weight-advice="RULE_DEAD_WEIGHT" />
|
||||
</div>
|
||||
<div class="statement">{{ r.statement }}</div>
|
||||
<div v-if="r.when_to_apply || r.updated_at" class="meta">
|
||||
<span v-if="r.when_to_apply" class="trigger">{{ r.when_to_apply }}</span>
|
||||
<span v-if="r.updated_at" class="age" :title="`Last changed ${r.updated_at}`">{{ r.updated_at }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<button class="new-rule" @click="emit('create-rule', topicId)">+ New rule</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/rules-shared.css" />
|
||||
<style scoped>
|
||||
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
li {
|
||||
padding: 0.75rem;
|
||||
@@ -36,5 +66,16 @@ li {
|
||||
li:hover { background: var(--fs-surface-hover); }
|
||||
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
||||
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
||||
.meta { display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.35rem; font-size: 0.75em; }
|
||||
.trigger { flex: 1; min-width: 0; color: var(--fs-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.age { color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
||||
/* Only the departures from .rule-chip (rules-shared.css) live here. */
|
||||
.check-chip { font-variant-numeric: tabular-nums; }
|
||||
/* No age-graded colour on purpose. The sweep is already ordered by urgency, so
|
||||
a red/amber ramp would restate the ordering AND require an invented "stale
|
||||
after N days" threshold — a magic number nobody could defend and the first
|
||||
thing to go out of date. Only "never" is marked, because it is categorically
|
||||
different from a date rather than a worse one. */
|
||||
.check-chip.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
|
||||
.new-rule { cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The staleness sweep: rules that assert a FACT, oldest verification first.
|
||||
*
|
||||
* Cross-cutting by nature — a rule that has gone false does not care which
|
||||
* rulebook it sits in — so this is its own pane rather than a filter on the
|
||||
* per-topic rule list. That list can only ever show one topic of one
|
||||
* rulebook, so filtering it would quietly under-report, which is the exact
|
||||
* failure this surface exists to catch.
|
||||
*/
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import type { RuleTier } from "@/api/rulebooks";
|
||||
|
||||
const emit = defineEmits<{ "open-rule": [id: number] }>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const neverOnly = ref(false);
|
||||
const tier = ref<RuleTier | "">("");
|
||||
const busyId = ref<number | null>(null);
|
||||
|
||||
function reload() {
|
||||
return store.fetchRulesDue({
|
||||
neverOnly: neverOnly.value || undefined,
|
||||
tier: tier.value || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function verify(id: number, stillTrue: boolean) {
|
||||
busyId.value = id;
|
||||
try {
|
||||
await store.verifyRule(id, stillTrue);
|
||||
} finally {
|
||||
busyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(reload);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pane sweep">
|
||||
<header>
|
||||
<h2>Due for verification</h2>
|
||||
<p class="lede">
|
||||
Rules that assert a fact about something outside your control. Most rules are
|
||||
decisions and never appear here — they have no truth value to go stale.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="filters">
|
||||
<label class="filter">
|
||||
<input v-model="neverOnly" type="checkbox" @change="reload" />
|
||||
<span>Never checked only</span>
|
||||
</label>
|
||||
<label class="filter">
|
||||
<span>Tier</span>
|
||||
<select v-model="tier" @change="reload">
|
||||
<option value="">any</option>
|
||||
<option value="always_on">always on</option>
|
||||
<option value="conditional">conditional</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="store.loading" class="state">Loading…</p>
|
||||
|
||||
<!-- An empty sweep is GOOD NEWS, and must not read like a broken page. -->
|
||||
<p v-else-if="!store.rulesDue.length" class="state empty">
|
||||
Nothing to check.
|
||||
{{ neverOnly || tier ? "No rule matches these filters." : "No rule carries a check yet — add one to a rule that asserts a fact." }}
|
||||
</p>
|
||||
|
||||
<ol v-else class="rows">
|
||||
<li v-for="r in store.rulesDue" :key="r.id" class="row">
|
||||
<div class="row-head">
|
||||
<button class="row-title" @click="emit('open-rule', r.id)">{{ r.title }}</button>
|
||||
<span v-if="r.tier === 'always_on'" class="rule-chip" title="Loaded into every session — a wrong one is wrong everywhere at once">always on</span>
|
||||
<span class="age" :class="{ unchecked: r.days_since_verified === null }">
|
||||
{{ r.days_since_verified === null
|
||||
? "never checked"
|
||||
: `${r.days_since_verified}d ago` }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="statement">{{ r.statement }}</p>
|
||||
|
||||
<dl class="check">
|
||||
<dt>Check</dt>
|
||||
<dd><code>{{ r.verify_with }}</code></dd>
|
||||
<template v-if="r.expires_when">
|
||||
<dt>Ends when</dt>
|
||||
<dd>{{ r.expires_when }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<div class="actions">
|
||||
<button :disabled="busyId === r.id" @click="verify(r.id, true)">Still true</button>
|
||||
<button :disabled="busyId === r.id" @click="verify(r.id, false)">No longer true</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p v-if="store.rulesDue.length" class="footnote">
|
||||
Record a result only after actually running the check. “No longer true” stores nothing
|
||||
on purpose — the rule is wrong rather than in a state worth recording, so it keeps its
|
||||
place here until you correct or retire it.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/rules-shared.css" />
|
||||
<style scoped>
|
||||
.sweep { display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
.lede {
|
||||
margin: 0;
|
||||
max-width: 62ch;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.filters { display: flex; gap: var(--fs-space-5); align-items: center; flex-wrap: wrap; }
|
||||
.filter { display: flex; align-items: center; gap: var(--fs-space-2); font-size: 0.82rem; color: var(--fs-text-secondary); }
|
||||
.filter input[type="checkbox"] { accent-color: var(--fs-accent); }
|
||||
.filter select {
|
||||
font: inherit; font-size: 0.82rem;
|
||||
background: var(--fs-surface-page); color: var(--fs-text-primary);
|
||||
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
.state { margin: 0; font-size: 0.9rem; color: var(--fs-text-secondary); }
|
||||
.state.empty { color: var(--fs-text-tertiary); }
|
||||
|
||||
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
.row {
|
||||
background: var(--fs-surface-raised);
|
||||
border-radius: var(--fs-radius-md);
|
||||
padding: var(--fs-space-3);
|
||||
}
|
||||
.row-head { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
|
||||
.row-title {
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
font-family: Fraunces, serif; font-style: italic; font-size: 1.02rem;
|
||||
color: var(--fs-text-primary); text-align: left;
|
||||
}
|
||||
.row-title:hover { text-decoration: underline; }
|
||||
/* The ORDER carries urgency — the top of this list is the most overdue thing
|
||||
in the rulebook. No red/amber ramp: it would restate the ordering and force
|
||||
an invented "stale after N days" threshold. "Never" is marked because it is
|
||||
categorically different from a date, not a worse one. */
|
||||
.age { margin-left: auto; font-size: 0.78rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
|
||||
.age.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
|
||||
|
||||
.statement { margin: 0.35rem 0 0; font-size: 0.88rem; color: var(--fs-text-secondary); }
|
||||
|
||||
.check { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem var(--fs-space-3); margin: var(--fs-space-3) 0 0; }
|
||||
.check dt { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fs-text-tertiary); }
|
||||
.check dd { margin: 0; font-size: 0.82rem; color: var(--fs-text-primary); min-width: 0; }
|
||||
.check code {
|
||||
font-family: var(--fs-font-mono);
|
||||
background: var(--fs-surface-code-inline);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.05rem 0.3rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.actions { display: flex; gap: var(--fs-space-2); margin-top: var(--fs-space-3); }
|
||||
.actions button {
|
||||
cursor: pointer; font: inherit; font-size: 0.78rem;
|
||||
background: var(--fs-surface-page); color: var(--fs-text-primary);
|
||||
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
|
||||
padding: 0.25rem 0.6rem;
|
||||
}
|
||||
.actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
|
||||
.actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
|
||||
|
||||
.footnote { margin: 0; max-width: 62ch; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
||||
</style>
|
||||
@@ -121,10 +121,9 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/rules-shared.css" />
|
||||
<style scoped>
|
||||
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
|
||||
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
.always-on-toggle {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
font-size: 0.85rem; opacity: 0.85; cursor: pointer;
|
||||
@@ -146,7 +145,6 @@ li:hover { background: var(--fs-surface-hover); }
|
||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.form-buttons { display: flex; gap: 0.5rem; }
|
||||
.subscriptions {
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
|
||||
@@ -3,8 +3,8 @@ import { ref } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import type { Rulebook } from "@/api/rulebooks";
|
||||
|
||||
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null }>();
|
||||
const emit = defineEmits<{ select: [id: number] }>();
|
||||
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null; sweepActive: boolean }>();
|
||||
const emit = defineEmits<{ select: [id: number]; "select-sweep": [] }>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const isCreating = ref(false);
|
||||
@@ -34,6 +34,18 @@ async function submitNew() {
|
||||
<span v-if="rb.always_on" class="always-on-badge" title="Loaded at session start">always on</span>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Not a rulebook, and deliberately below them: a cross-cutting view over
|
||||
every rule the operator owns. It lives here because this is where you
|
||||
come to look at rules, and a rule that has gone false belongs to no
|
||||
one rulebook. -->
|
||||
<button
|
||||
class="sweep-entry"
|
||||
:class="{ active: sweepActive }"
|
||||
@click="emit('select-sweep')"
|
||||
>
|
||||
Due for verification
|
||||
</button>
|
||||
|
||||
<div class="new-rulebook">
|
||||
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
|
||||
<form v-else @submit.prevent="submitNew">
|
||||
@@ -47,9 +59,8 @@ async function submitNew() {
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/rules-shared.css" />
|
||||
<style scoped>
|
||||
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
|
||||
li.active { background: var(--fs-accent-soft); }
|
||||
@@ -64,6 +75,15 @@ li:hover { background: var(--fs-surface-hover); }
|
||||
color: var(--fs-text-on-action);
|
||||
margin-left: auto;
|
||||
}
|
||||
.sweep-entry {
|
||||
display: block; width: 100%; text-align: left;
|
||||
margin-top: var(--fs-space-3);
|
||||
padding: 0.5rem; border-radius: 6px;
|
||||
background: none; border: 1px dashed var(--fs-border-color);
|
||||
color: var(--fs-text-secondary); font: inherit; cursor: pointer;
|
||||
}
|
||||
.sweep-entry:hover { background: var(--fs-surface-hover); }
|
||||
.sweep-entry.active { background: var(--fs-accent-soft); color: var(--fs-text-primary); }
|
||||
.new-rulebook { margin-top: 1rem; }
|
||||
.new-rulebook input {
|
||||
width: 100%; margin-bottom: 0.5rem;
|
||||
@@ -71,6 +91,5 @@ li:hover { background: var(--fs-surface-hover); }
|
||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.form-buttons { display: flex; gap: 0.5rem; }
|
||||
button { cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ref, computed, watch, type Ref } from "vue";
|
||||
import { computeDiff, type DiffLine } from "@/utils/diff";
|
||||
import { apiPost, apiPut, apiDelete, apiSSEStream, type SSEStreamHandle } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import {
|
||||
@@ -9,17 +10,16 @@ import {
|
||||
export type AssistState = "idle" | "streaming" | "review";
|
||||
export type ScopeMode = "document" | "section";
|
||||
|
||||
// Re-exported: this composable was where DiffLine lived before the diff
|
||||
// moved to a shared util, and every consumer still imports the type from here.
|
||||
export type { DiffLine };
|
||||
|
||||
export interface AssistTarget {
|
||||
text: string;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
}
|
||||
|
||||
export interface DiffLine {
|
||||
type: 'equal' | 'delete' | 'insert';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface NoteDraft {
|
||||
id: number;
|
||||
note_id: number;
|
||||
@@ -31,27 +31,6 @@ export interface NoteDraft {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function computeDiff(a: string, b: string): DiffLine[] {
|
||||
const aLines = a.split('\n');
|
||||
const bLines = b.split('\n');
|
||||
const m = aLines.length, n = bLines.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = m - 1; i >= 0; i--)
|
||||
for (let j = n - 1; j >= 0; j--)
|
||||
dp[i][j] = aLines[i] === bLines[j]
|
||||
? dp[i+1][j+1] + 1
|
||||
: Math.max(dp[i+1][j], dp[i][j+1]);
|
||||
const result: DiffLine[] = [];
|
||||
let i = 0, j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (aLines[i] === bLines[j]) { result.push({ type: 'equal', text: aLines[i++] }); j++; }
|
||||
else if (dp[i+1][j] >= dp[i][j+1]) result.push({ type: 'delete', text: aLines[i++] });
|
||||
else result.push({ type: 'insert', text: bLines[j++] });
|
||||
}
|
||||
while (i < m) result.push({ type: 'delete', text: aLines[i++] });
|
||||
while (j < n) result.push({ type: 'insert', text: bLines[j++] });
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useAssist(body: Ref<string>, noteId?: Ref<number | null>, projectId?: Ref<number | null>) {
|
||||
const toast = useToastStore();
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import * as api from "@/api/canonicalSystems";
|
||||
import type { CanonicalSystem, MappingProposal } from "@/api/canonicalSystems";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
|
||||
/**
|
||||
* The global area catalog (milestone 307). Shared by every project, so it is
|
||||
* fetched ONCE per session rather than per project — the whole point of the
|
||||
* table is that it is the same list everywhere.
|
||||
*/
|
||||
export const useCanonicalSystemsStore = defineStore("canonicalSystems", () => {
|
||||
const catalog = ref<CanonicalSystem[]>([]);
|
||||
const loaded = ref(false);
|
||||
const loading = ref(false);
|
||||
const proposalsByProject = ref<Record<number, MappingProposal[]>>({});
|
||||
|
||||
async function fetchCatalog(force = false) {
|
||||
if (loaded.value && !force) return catalog.value;
|
||||
loading.value = true;
|
||||
try {
|
||||
catalog.value = await api.listCanonicalSystems();
|
||||
loaded.value = true;
|
||||
} catch {
|
||||
// A naming aid must never break the screen it rides on — an empty
|
||||
// catalog degrades the suggestion, it does not fail the form.
|
||||
catalog.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
return catalog.value;
|
||||
}
|
||||
|
||||
function byId(id: number | null): CanonicalSystem | undefined {
|
||||
if (id == null) return undefined;
|
||||
return catalog.value.find((c) => c.id === id);
|
||||
}
|
||||
|
||||
async function fetchProposals(projectId: number) {
|
||||
proposalsByProject.value[projectId] = await api.proposeMappings(projectId);
|
||||
return proposalsByProject.value[projectId];
|
||||
}
|
||||
|
||||
/** Apply or clear one mapping, then drop it from the pending proposals. */
|
||||
async function mapSystem(projectId: number, systemId: number, canonicalId: number | null) {
|
||||
try {
|
||||
await api.mapSystem(systemId, canonicalId);
|
||||
} catch (e) {
|
||||
useToastStore().show(apiErrorMessage(e, "Failed to map system"), "error");
|
||||
throw e;
|
||||
}
|
||||
dismissProposal(projectId, systemId);
|
||||
}
|
||||
|
||||
/** Remove a proposal from the pending list without writing anything. */
|
||||
function dismissProposal(projectId: number, systemId: number) {
|
||||
const list = proposalsByProject.value[projectId];
|
||||
if (list) {
|
||||
proposalsByProject.value[projectId] = list.filter((p) => p.system_id !== systemId);
|
||||
}
|
||||
}
|
||||
|
||||
async function createEntry(data: { name: string; description?: string }) {
|
||||
const entry = await api.createCanonicalSystem(data);
|
||||
catalog.value.push(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function updateEntry(
|
||||
id: number,
|
||||
data: Partial<{ name: string; description: string; order_index: number }>,
|
||||
) {
|
||||
const entry = await api.updateCanonicalSystem(id, data);
|
||||
const idx = catalog.value.findIndex((c) => c.id === id);
|
||||
if (idx >= 0) catalog.value[idx] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
return {
|
||||
catalog,
|
||||
loaded,
|
||||
loading,
|
||||
proposalsByProject,
|
||||
fetchCatalog,
|
||||
byId,
|
||||
fetchProposals,
|
||||
mapSystem,
|
||||
dismissProposal,
|
||||
createEntry,
|
||||
updateEntry,
|
||||
};
|
||||
});
|
||||
@@ -31,6 +31,8 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
project_id?: number | null;
|
||||
milestone_id?: number | null;
|
||||
note_type?: string;
|
||||
verify_with?: string;
|
||||
expires_when?: string;
|
||||
}): Promise<Note> {
|
||||
try {
|
||||
return await apiPost<Note>("/api/notes", data);
|
||||
@@ -42,7 +44,11 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
|
||||
async function updateNote(
|
||||
id: number,
|
||||
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type">>
|
||||
data: Partial<Pick<
|
||||
Note,
|
||||
"title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type"
|
||||
| "verify_with" | "expires_when"
|
||||
>>
|
||||
): Promise<Note> {
|
||||
try {
|
||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||
|
||||
@@ -9,6 +9,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
const topicsByRulebook = ref<Record<number, RulebookTopic[]>>({});
|
||||
const rulesByTopic = ref<Record<number, RuleHeader[]>>({});
|
||||
const currentRule = ref<Rule | null>(null);
|
||||
const rulesDue = ref<api.RuleVerificationRow[]>([]);
|
||||
// Kept so a verify re-reads the sweep with the SAME filters the operator is
|
||||
// looking at — re-fetching unfiltered would silently widen the list under
|
||||
// them at the moment they acted on it.
|
||||
const lastSweepOpts = ref<{ olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean }>({});
|
||||
const loading = ref(false);
|
||||
|
||||
async function fetchRulebooks() {
|
||||
@@ -35,9 +40,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
async function fetchRules(topicId: number) {
|
||||
try {
|
||||
const rules = await api.listRules({ topic_id: topicId });
|
||||
rulesByTopic.value[topicId] = rules.map((r) => ({
|
||||
id: r.id, title: r.title, statement: r.statement, topic_id: r.topic_id,
|
||||
}));
|
||||
rulesByTopic.value[topicId] = rules.map(toHeader);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load rules", "error");
|
||||
throw e;
|
||||
@@ -98,24 +101,100 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
delete rulesByTopic.value[id];
|
||||
}
|
||||
|
||||
async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string }) {
|
||||
/**
|
||||
* A list row built from a full rule. The row shape is the server's
|
||||
* rule_brief, so every field it carries has to be mirrored here or the two
|
||||
* disagree the moment a list is patched locally instead of re-fetched.
|
||||
*/
|
||||
function toHeader(rule: Rule): api.RuleHeader {
|
||||
return {
|
||||
id: rule.id,
|
||||
title: rule.title,
|
||||
statement: rule.statement,
|
||||
topic_id: rule.topic_id,
|
||||
tier: rule.tier,
|
||||
updated_at: rule.updated_at,
|
||||
when_to_apply: rule.when_to_apply || undefined,
|
||||
arose_from_id: rule.arose_from_id ?? undefined,
|
||||
// Mirrors services.rulebooks.last_verified_label: present ONLY when the
|
||||
// rule carries a check, and "never" rather than absent when it has one
|
||||
// nobody has run. Computed here so a row just written looks identical to
|
||||
// the same row re-fetched, instead of losing its chip until a reload.
|
||||
last_verified: rule.verify_with
|
||||
? (rule.verified_at ? rule.verified_at.slice(0, 10) : "never")
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function createRule(topicId: number, data: Partial<api.RuleWrite> & { title: string; statement: string }) {
|
||||
const rule = await api.createRule(topicId, data);
|
||||
if (!rulesByTopic.value[topicId]) rulesByTopic.value[topicId] = [];
|
||||
rulesByTopic.value[topicId].push({ id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id });
|
||||
rulesByTopic.value[topicId].push(toHeader(rule));
|
||||
return rule;
|
||||
}
|
||||
|
||||
async function updateRule(id: number, data: Partial<Pick<Rule, "title" | "statement" | "why" | "how_to_apply" | "order_index">>) {
|
||||
async function updateRule(id: number, data: Partial<api.RuleWrite>) {
|
||||
const rule = await api.updateRule(id, data);
|
||||
if (currentRule.value?.id === id) currentRule.value = rule;
|
||||
for (const tid of Object.keys(rulesByTopic.value)) {
|
||||
const list = rulesByTopic.value[Number(tid)];
|
||||
const idx = list.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) list[idx] = { id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id };
|
||||
if (idx >= 0) list[idx] = toHeader(rule);
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
async function relateRules(
|
||||
fromRuleId: number,
|
||||
data: { to_rule_id: number; kind: api.RuleRelationKind; note?: string },
|
||||
) {
|
||||
await api.relateRules(fromRuleId, data);
|
||||
// Re-read rather than patching locally: the edge reads from BOTH ends, so
|
||||
// the far rule's relations changed too and a local splice would show only
|
||||
// half of what just happened.
|
||||
await fetchRule(fromRuleId);
|
||||
}
|
||||
|
||||
async function unrelateRules(relationId: number, refreshRuleId: number) {
|
||||
await api.unrelateRules(relationId);
|
||||
await fetchRule(refreshRuleId);
|
||||
}
|
||||
|
||||
/** The staleness sweep: rules asserting a fact, oldest verification first. */
|
||||
async function fetchRulesDue(opts: {
|
||||
olderThanDays?: number; tier?: api.RuleTier; neverOnly?: boolean;
|
||||
} = {}) {
|
||||
loading.value = true;
|
||||
lastSweepOpts.value = opts;
|
||||
try {
|
||||
const data = await api.listRulesDueForVerification(opts);
|
||||
rulesDue.value = data.rules;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a rule's check was RUN, and what it said.
|
||||
*
|
||||
* A pass re-sorts the row to the back of the sweep, so the list is re-read
|
||||
* rather than patched: the whole point of this surface is an ORDER, and a
|
||||
* locally-mutated row would sit in its old position claiming a new date.
|
||||
* A failure writes nothing server-side and the row keeps its place — also
|
||||
* correct, and also what a re-read shows.
|
||||
*/
|
||||
async function verifyRule(id: number, stillTrue: boolean) {
|
||||
const rule = await api.markRuleVerified(id, stillTrue);
|
||||
if (currentRule.value?.id === id) currentRule.value = rule;
|
||||
for (const tid of Object.keys(rulesByTopic.value)) {
|
||||
const list = rulesByTopic.value[Number(tid)];
|
||||
const idx = list.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) list[idx] = toHeader(rule);
|
||||
}
|
||||
if (rulesDue.value.length) await fetchRulesDue(lastSweepOpts.value);
|
||||
return rule;
|
||||
}
|
||||
|
||||
async function deleteRule(id: number) {
|
||||
await api.deleteRule(id);
|
||||
if (currentRule.value?.id === id) currentRule.value = null;
|
||||
@@ -125,10 +204,11 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
}
|
||||
|
||||
return {
|
||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading,
|
||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
|
||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
||||
createTopic, updateTopic, deleteTopic,
|
||||
createRule, updateRule, deleteRule,
|
||||
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
||||
fetchRulesDue, verifyRule,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ export const useSystemsStore = defineStore("systems", () => {
|
||||
|
||||
async function createSystem(
|
||||
projectId: number,
|
||||
data: { name: string; description?: string; color?: string },
|
||||
data: { name: string; description?: string; color?: string; canonical_id?: number },
|
||||
) {
|
||||
const system = await api.createSystem(projectId, data);
|
||||
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
|
||||
|
||||
@@ -55,6 +55,9 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
|
||||
async function updateTask(
|
||||
id: number,
|
||||
// IssueFields carries `kind`, which the PATCH route now reads. It has
|
||||
// always been SENT by the task editor; until #3129 the route dropped it
|
||||
// and the save reported success while changing nothing.
|
||||
data: Partial<
|
||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
|
||||
> & IssueFields
|
||||
|
||||
@@ -2,7 +2,16 @@ import type { System } from "@/api/systems";
|
||||
|
||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||
export type TaskKind = "work" | "plan" | "issue";
|
||||
/**
|
||||
* What KIND of work a task is, not how it is going.
|
||||
* work — ships a change (default)
|
||||
* issue — corrective; something was broken
|
||||
* spike — time-boxed, output is knowledge; it succeeds by producing an
|
||||
* answer and nothing ships at the end of it
|
||||
* plan — retired (plans are milestones); kept so historical plan-tasks
|
||||
* still render their kind
|
||||
*/
|
||||
export type TaskKind = "work" | "plan" | "issue" | "spike";
|
||||
export type NoteType = "note" | "process" | "snippet";
|
||||
|
||||
export interface Note {
|
||||
@@ -25,6 +34,15 @@ export interface Note {
|
||||
is_task: boolean;
|
||||
note_type: NoteType;
|
||||
task_kind?: TaskKind;
|
||||
// The note's own check (milestone 317). Empty on almost every note — that
|
||||
// is the normal case: a note with no `verify_with` is a DECISION, and there
|
||||
// is nothing to go and check. Only a note asserting a fact about something
|
||||
// outside the operator's control carries one. `verified_at` null while
|
||||
// `verify_with` is set means NOBODY HAS EVER CONFIRMED IT, which is the
|
||||
// state the sweep ranks first.
|
||||
verify_with?: string;
|
||||
expires_when?: string;
|
||||
verified_at?: string | null;
|
||||
systems?: System[];
|
||||
arose_from_id?: number | null;
|
||||
created_at: string;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* How often a record was put in front of an agent, and how often one then
|
||||
* opened it in full.
|
||||
*
|
||||
* One shape for every record kind the retrieval surfaces can choose. Snippets
|
||||
* and notes are counted in `note_usage_events`; rules in `rule_usage_events`,
|
||||
* which is a separate table because a note id and a rule id are different
|
||||
* namespaces resolved through different maps at restore (milestone 333). The
|
||||
* TABLES are separate for that reason; the READOUT is the same question, so
|
||||
* the client type is one.
|
||||
*
|
||||
* A high `surfaced_count` with `pull_count: 0` is dead weight — it occupies a
|
||||
* slot in every future menu while never being used. What to DO about that
|
||||
* differs by kind, which is why the advice is a prop on the badge rather than
|
||||
* a property of this type: a snippet nobody opens should probably be deleted,
|
||||
* while a rule nobody opens may just be mis-triggered.
|
||||
*/
|
||||
export interface RecordUsage {
|
||||
surfaced_count: number;
|
||||
pull_count: number;
|
||||
last_surfaced_at: string | null;
|
||||
last_pulled_at: string | null;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Line diff — one copy, for every surface that shows what changed.
|
||||
*
|
||||
* WHY THIS FILE EXISTS. The same LCS walk was written out three times:
|
||||
* privately in `useAssist.ts`, and again inside `HistoryPanel.vue` and
|
||||
* `VersionHistorySection.vue`. The three were character-identical apart from
|
||||
* quote style — nobody had diverged them on purpose, they were simply copied
|
||||
* because `computeDiff` was never exported. Milestone 323 needed a fourth
|
||||
* consumer (a rule's edit history), and a fourth copy is the cost #3207
|
||||
* records: a fix or an improvement now has to be found in N places by someone
|
||||
* who does not know N.
|
||||
*/
|
||||
|
||||
export interface DiffLine {
|
||||
type: "equal" | "delete" | "insert";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff `a` against `b`, line by line.
|
||||
*
|
||||
* `delete` lines come from `a`, `insert` lines from `b` — so the caller
|
||||
* decides which side reads as "before" by which argument it passes. Every
|
||||
* caller here passes the CURRENT text as `a` and the older text as `b`, so a
|
||||
* deletion is what the old version had and an insertion is what replaced it.
|
||||
*
|
||||
* O(m·n) in time and memory: fine for a note or a rule, and deliberately not
|
||||
* generalised further, since nothing here diffs a file of thousands of lines.
|
||||
*/
|
||||
export function computeDiff(a: string, b: string): DiffLine[] {
|
||||
const aLines = a.split("\n");
|
||||
const bLines = b.split("\n");
|
||||
const m = aLines.length, n = bLines.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = m - 1; i >= 0; i--)
|
||||
for (let j = n - 1; j >= 0; j--)
|
||||
dp[i][j] = aLines[i] === bLines[j]
|
||||
? dp[i + 1][j + 1] + 1
|
||||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
const result: DiffLine[] = [];
|
||||
let i = 0, j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (aLines[i] === bLines[j]) { result.push({ type: "equal", text: aLines[i++] }); j++; }
|
||||
else if (dp[i + 1][j] >= dp[i][j + 1]) result.push({ type: "delete", text: aLines[i++] });
|
||||
else result.push({ type: "insert", text: bLines[j++] });
|
||||
}
|
||||
while (i < m) result.push({ type: "delete", text: aLines[i++] });
|
||||
while (j < n) result.push({ type: "insert", text: bLines[j++] });
|
||||
return result;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import KindBadge from "@/components/KindBadge.vue";
|
||||
import type { TaskKind } from "@/types/note";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
|
||||
interface TaskRow { id: number; title: string; status: string; priority: string }
|
||||
interface TaskRow { id: number; title: string; status: string; priority: string; task_kind?: TaskKind }
|
||||
interface MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
|
||||
interface ActiveProject {
|
||||
id: number; title: string; color: string | null; last_activity: string;
|
||||
@@ -99,6 +101,7 @@ onMounted(async () => {
|
||||
>
|
||||
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||
<span class="task-title">{{ t.title }}</span>
|
||||
<KindBadge :kind="t.task_kind" />
|
||||
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -114,6 +117,7 @@ onMounted(async () => {
|
||||
>
|
||||
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||
<span class="task-title">{{ t.title }}</span>
|
||||
<KindBadge :kind="t.task_kind" />
|
||||
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
@@ -557,14 +557,14 @@ function isSelfContainedColour(value: string): boolean {
|
||||
<div class="field">
|
||||
<label class="field-label" for="first-title">Title</label>
|
||||
<input
|
||||
id="first-title" v-model="newTitle" class="input" type="text"
|
||||
id="first-title" v-model="newTitle" class="fs-input input" type="text"
|
||||
placeholder="Your house style" @keyup.enter="submitCreate"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="first-desc">Description</label>
|
||||
<input
|
||||
id="first-desc" v-model="newDescription" class="input" type="text"
|
||||
id="first-desc" v-model="newDescription" class="fs-input input" type="text"
|
||||
placeholder="What it covers"
|
||||
/>
|
||||
</div>
|
||||
@@ -612,20 +612,20 @@ function isSelfContainedColour(value: string): boolean {
|
||||
<div class="field">
|
||||
<label class="field-label" for="new-title">Title</label>
|
||||
<input
|
||||
id="new-title" v-model="newTitle" class="input" type="text"
|
||||
id="new-title" v-model="newTitle" class="fs-input input" type="text"
|
||||
placeholder="A house style, or one app in it" @keyup.enter="submitCreate"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="new-desc">Description</label>
|
||||
<input
|
||||
id="new-desc" v-model="newDescription" class="input" type="text"
|
||||
id="new-desc" v-model="newDescription" class="fs-input input" type="text"
|
||||
placeholder="What it covers"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="new-parent">Inherits from</label>
|
||||
<select id="new-parent" v-model="newParentId" class="input">
|
||||
<select id="new-parent" v-model="newParentId" class="fs-input input">
|
||||
<option :value="null">Nothing — this is a family system</option>
|
||||
<option v-for="s in systems" :key="s.id" :value="s.id">{{ s.title }}</option>
|
||||
</select>
|
||||
@@ -659,16 +659,16 @@ function isSelfContainedColour(value: string): boolean {
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="edit-title">Title</label>
|
||||
<input id="edit-title" v-model="editTitle" class="input" type="text" />
|
||||
<input id="edit-title" v-model="editTitle" class="fs-input input" type="text" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="edit-desc">Description</label>
|
||||
<input id="edit-desc" v-model="editDescription" class="input" type="text" />
|
||||
<input id="edit-desc" v-model="editDescription" class="fs-input input" type="text" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="edit-guidance">Guidance</label>
|
||||
<textarea
|
||||
id="edit-guidance" v-model="editGuidance" class="input" rows="5"
|
||||
id="edit-guidance" v-model="editGuidance" class="fs-input input" rows="5"
|
||||
placeholder="Aesthetic, voice and tone, what's deliberately out of scope…"
|
||||
></textarea>
|
||||
<p class="field-hint">
|
||||
@@ -678,7 +678,7 @@ function isSelfContainedColour(value: string): boolean {
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="edit-parent">Inherits from</label>
|
||||
<select id="edit-parent" v-model="editParentId" class="input">
|
||||
<select id="edit-parent" v-model="editParentId" class="fs-input input">
|
||||
<option :value="null">Nothing — this is a family system</option>
|
||||
<option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.title }}</option>
|
||||
</select>
|
||||
@@ -887,19 +887,19 @@ function isSelfContainedColour(value: string): boolean {
|
||||
<div class="field">
|
||||
<label class="field-label" for="token-name">Name</label>
|
||||
<input
|
||||
id="token-name" v-model="tokenName" class="input mono" type="text"
|
||||
id="token-name" v-model="tokenName" class="fs-input input mono" type="text"
|
||||
placeholder="--surface-page"
|
||||
/>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label class="field-label" for="token-group">Group</label>
|
||||
<input id="token-group" v-model="tokenGroup" class="input" type="text" placeholder="surface" />
|
||||
<input id="token-group" v-model="tokenGroup" class="fs-input input" type="text" placeholder="surface" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="token-purpose">Purpose</label>
|
||||
<input
|
||||
id="token-purpose" v-model="tokenPurpose" class="input" type="text"
|
||||
id="token-purpose" v-model="tokenPurpose" class="fs-input input" type="text"
|
||||
placeholder="page background, deepest surface"
|
||||
/>
|
||||
</div>
|
||||
@@ -908,7 +908,7 @@ function isSelfContainedColour(value: string): boolean {
|
||||
<div class="field">
|
||||
<label class="field-label" for="token-rationale">Why this value</label>
|
||||
<input
|
||||
id="token-rationale" v-model="tokenRationale" class="input" type="text"
|
||||
id="token-rationale" v-model="tokenRationale" class="fs-input input" type="text"
|
||||
placeholder="Matches the primary action colour, deliberately"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
@@ -920,7 +920,7 @@ function isSelfContainedColour(value: string): boolean {
|
||||
<div class="field">
|
||||
<label class="field-label" for="token-supersedes">Use instead of</label>
|
||||
<input
|
||||
id="token-supersedes" v-model="tokenSupersedes" class="input mono" type="text"
|
||||
id="token-supersedes" v-model="tokenSupersedes" class="fs-input input mono" type="text"
|
||||
placeholder="#fff, #ffffff"
|
||||
/>
|
||||
<p class="field-hint">
|
||||
@@ -941,8 +941,8 @@ function isSelfContainedColour(value: string): boolean {
|
||||
</template>
|
||||
</p>
|
||||
<div v-for="(row, i) in tokenModes" :key="i" class="mode-row">
|
||||
<input v-model="row.mode" class="input mono mode-key" type="text" placeholder="base" />
|
||||
<input v-model="row.value" class="input mono" type="text" placeholder="#14171a" />
|
||||
<input v-model="row.mode" class="fs-input input mono mode-key" type="text" placeholder="base" />
|
||||
<input v-model="row.value" class="fs-input input mono" type="text" placeholder="#14171a" />
|
||||
<span
|
||||
v-if="isSelfContainedColour(row.value)" class="swatch"
|
||||
:style="{ background: row.value }" aria-hidden="true"
|
||||
@@ -1287,20 +1287,13 @@ function isSelfContainedColour(value: string): boolean {
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
line-height: 1.5;
|
||||
line-height: 1.5; /* remainder over the shared recipe */
|
||||
}
|
||||
|
||||
/* remainder over .fs-input (components.css, canon #2336; m302) */
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.6rem;
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
color: var(--fs-text-primary);
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
@@ -1434,12 +1427,12 @@ textarea.input {
|
||||
|
||||
.spec-status.violated {
|
||||
background: var(--fs-priority-high-bg);
|
||||
color: var(--fs-priority-high);
|
||||
color: var(--fs-priority-high-fg);
|
||||
}
|
||||
|
||||
.spec-status.missing {
|
||||
background: var(--fs-priority-medium-bg);
|
||||
color: var(--fs-priority-medium);
|
||||
color: var(--fs-priority-medium-fg);
|
||||
}
|
||||
|
||||
.sheet {
|
||||
|
||||
@@ -774,7 +774,7 @@ onUnmounted(() => {
|
||||
.tag-chip {
|
||||
font-size: 0.7rem;
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
}
|
||||
@@ -928,7 +928,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.peek-linked-item:hover {
|
||||
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
}
|
||||
|
||||
.peek-linked-type {
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiGet } from "@/api/client";
|
||||
import type { TaskKind, TaskStatus, TaskPriority } from "@/types/note";
|
||||
import KindBadge from "@/components/KindBadge.vue";
|
||||
import NoteSweepPane from "@/components/NoteSweepPane.vue";
|
||||
import StatusBadge from "@/components/StatusBadge.vue";
|
||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import GraphView from "@/views/GraphView.vue";
|
||||
import {
|
||||
FileText,
|
||||
@@ -9,6 +14,7 @@ import {
|
||||
Workflow,
|
||||
Search,
|
||||
Share2,
|
||||
ShieldCheck,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
@@ -20,7 +26,7 @@ const router = useRouter();
|
||||
|
||||
interface KnowledgeItem {
|
||||
id: number;
|
||||
note_type: "note" | "task" | "process";
|
||||
note_type: "note" | "task" | "process" | "snippet";
|
||||
title: string;
|
||||
snippet: string;
|
||||
tags: string[];
|
||||
@@ -35,12 +41,44 @@ interface KnowledgeItem {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
due_date?: string;
|
||||
task_kind?: "work" | "plan";
|
||||
task_kind?: TaskKind;
|
||||
}
|
||||
|
||||
// ─── The facet vocabulary ─────────────────────────────────────────────────────
|
||||
// Mirrors services/knowledge._FACETS, which is where it is defined for real.
|
||||
// A facet spans BOTH typing axes — a record TYPE (note / process / snippet) or
|
||||
// a task KIND (`task` for any, else issue / spike) — because that is what this
|
||||
// feed actually holds.
|
||||
//
|
||||
// `plan` is still a valid facet at the API, for the 90 legacy plan-tasks, but
|
||||
// it has no chip: retired in 0066, it kept a chip of its own for longer than
|
||||
// `issue` — 17% of every task here — went without one (#3128). Those rows are
|
||||
// still reachable under Tasks, wearing a Plan badge.
|
||||
type Facet = "" | "note" | "task" | "issue" | "spike" | "snippet" | "process";
|
||||
|
||||
// The facets that select TASKS. Kinds are subsets of `task`, so any of them
|
||||
// means the duplicate report should be comparing tasks.
|
||||
const TASK_FACETS = new Set<Facet>(["task", "issue", "spike"]);
|
||||
|
||||
const FACET_CHIPS: [Exclude<Facet, "">, string][] = [
|
||||
["note", "Notes"],
|
||||
["task", "Tasks"],
|
||||
["issue", "Issues"],
|
||||
["spike", "Spikes"],
|
||||
["snippet", "Snippets"],
|
||||
["process", "Processes"],
|
||||
];
|
||||
|
||||
// ─── View mode ────────────────────────────────────────────────────────────────
|
||||
// The sweep is cross-cutting — a note that has gone false does not care which
|
||||
// facet it sits under — so it REPLACES the browse list rather than filtering
|
||||
// it. Filtering would mean the answer depended on which chip was active, which
|
||||
// is the under-reporting the sweep exists to prevent (milestone 317 step 4).
|
||||
const sweepActive = ref(false);
|
||||
|
||||
// ─── Filter state ─────────────────────────────────────────────────────────────
|
||||
|
||||
const activeType = ref<"" | "note" | "task" | "plan" | "process">("");
|
||||
const activeType = ref<Facet>("");
|
||||
const activeTag = ref("");
|
||||
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||
const searchQuery = ref("");
|
||||
@@ -66,9 +104,10 @@ const dupGroups = ref<DupGroup[]>([]);
|
||||
const dupSuggestion = ref("");
|
||||
const dupLoading = ref(false);
|
||||
const dupChecked = ref(false);
|
||||
// The report follows the type filter: viewing tasks checks tasks. Anything
|
||||
// else (all / plan / process) checks notes — the kind with the most to find.
|
||||
const dupKind = computed(() => (activeType.value === "task" ? "task" : "note"));
|
||||
// The report follows the type filter: viewing tasks — under ANY task facet,
|
||||
// including a single kind — checks tasks. Everything else checks notes, the
|
||||
// kind with the most to find.
|
||||
const dupKind = computed(() => (TASK_FACETS.has(activeType.value) ? "task" : "note"));
|
||||
|
||||
async function loadDuplicates() {
|
||||
dupLoading.value = true;
|
||||
@@ -92,8 +131,11 @@ watch(dupKind, () => { dupChecked.value = false; dupGroups.value = []; });
|
||||
|
||||
// ─── Type counts ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface KnowledgeCounts { note: number; task: number; plan: number; process: number; total: number }
|
||||
const typeCounts = ref<KnowledgeCounts>({ note: 0, task: 0, plan: 0, process: 0, total: 0 });
|
||||
// One number per facet, plus the grand total. Partial because the server sends
|
||||
// a key only for a facet it has rows for. Kinds are subsets of `task` and are
|
||||
// deliberately absent from `total` — including them would count an issue twice.
|
||||
type KnowledgeCounts = Partial<Record<Exclude<Facet, "">, number>> & { total: number };
|
||||
const typeCounts = ref<KnowledgeCounts>({ total: 0 });
|
||||
|
||||
async function fetchCounts() {
|
||||
try {
|
||||
@@ -230,6 +272,10 @@ function onSearchInput() {
|
||||
}
|
||||
|
||||
watch([activeType, sortMode], () => resetAndReobserve());
|
||||
// Closing the sweep remounts the feed, and with it the scroll sentinel — a
|
||||
// fresh element the old observer is not watching. Without this the list loads
|
||||
// its first page and then never loads another.
|
||||
watch(sweepActive, (open) => { if (!open) resetAndReobserve(); });
|
||||
watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
|
||||
|
||||
// ─── Today bar ────────────────────────────────────────────────────────────────
|
||||
@@ -270,9 +316,18 @@ function isOverdue(item: KnowledgeItem): boolean {
|
||||
return new Date(item.due_date) < new Date(new Date().toDateString());
|
||||
}
|
||||
|
||||
// Each record kind opens in ITS OWN editor. A snippet used to fall through to
|
||||
// /notes/:id, whose save is a plain PATCH of the body — which left the snippet's
|
||||
// derived `data` mirror describing the previous version (#3128). The service now
|
||||
// recomposes the mirror either way, so this is no longer the guard; it is simply
|
||||
// that the note editor cannot edit a snippet's signature, language or locations,
|
||||
// and offering it as the way in was always wrong. Processes stay here on
|
||||
// purpose: they have no editor of their own and the note editor knows the type.
|
||||
function openItem(item: KnowledgeItem) {
|
||||
if (item.note_type === 'task') {
|
||||
router.push(`/tasks/${item.id}`);
|
||||
} else if (item.note_type === 'snippet') {
|
||||
router.push(`/snippets/${item.id}`);
|
||||
} else {
|
||||
router.push(`/notes/${item.id}`);
|
||||
}
|
||||
@@ -376,14 +431,14 @@ onUnmounted(() => {
|
||||
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['process','Processes','process']] as [string,string,string][])"
|
||||
v-for="[val, label] in FACET_CHIPS"
|
||||
:key="val"
|
||||
class="filter-btn"
|
||||
:class="{ active: activeType === val }"
|
||||
@click="activeType = (val as '' | 'note' | 'task' | 'plan' | 'process')"
|
||||
@click="activeType = val"
|
||||
>
|
||||
<span class="filter-btn-label">{{ label }}</span>
|
||||
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
||||
<span v-if="(typeCounts[val] ?? 0) > 1" class="filter-count">{{ typeCounts[val] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -429,6 +484,15 @@ onUnmounted(() => {
|
||||
<Share2 :size="16" />
|
||||
Graph
|
||||
</button>
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
:class="{ active: sweepActive }"
|
||||
title="Notes that assert a fact about something outside your control, least-recently-confirmed first. Most notes are decisions and never appear."
|
||||
@click="sweepActive = !sweepActive"
|
||||
>
|
||||
<ShieldCheck :size="16" />
|
||||
Due
|
||||
</button>
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
:disabled="dupLoading"
|
||||
@@ -439,6 +503,12 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- The sweep replaces the feed. It is not a facet: a facet answers
|
||||
"show me this kind", and this answers "show me what nobody has
|
||||
confirmed" — a question the type chips cannot narrow without
|
||||
under-reporting it. -->
|
||||
<NoteSweepPane v-if="sweepActive" @open-note="(id) => router.push(`/notes/${id}`)" />
|
||||
|
||||
<!-- Near-duplicate report. A proposal surface only: unlike snippets
|
||||
(which merge losslessly), notes are never merged — the right fix is
|
||||
supersession, extraction into a reference note, or leaving parallel
|
||||
@@ -476,6 +546,12 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- The whole feed stands down while the sweep is open: two answers
|
||||
to two different questions on one screen is neither. Wrapped
|
||||
rather than given an extra v-if branch, because the scroll
|
||||
sentinel lives inside the grid and the observer must not be left
|
||||
holding a ref to something that never renders. -->
|
||||
<template v-if="!sweepActive">
|
||||
<!-- Loading / empty -->
|
||||
<div v-if="loading && items.length === 0" class="knowledge-empty">Loading…</div>
|
||||
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
|
||||
@@ -497,7 +573,14 @@ onUnmounted(() => {
|
||||
<span v-if="item.note_type === 'note'">Note</span>
|
||||
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
|
||||
<span v-else-if="item.note_type === 'process'">Process</span>
|
||||
<span v-else-if="item.note_type === 'snippet'">Snippet</span>
|
||||
</span>
|
||||
<!-- Kind sits BESIDE the type badge, not inside it: the type badge
|
||||
speaks the vocabulary of this view's type filter (note / task /
|
||||
plan / process), and kind is the other axis. `plan` is passed
|
||||
as null because the badge to the left already says it — two
|
||||
chips reading "Plan" would look like two facts. -->
|
||||
<KindBadge :kind="item.task_kind === 'plan' ? null : item.task_kind" />
|
||||
|
||||
<div class="k-card-body">
|
||||
<div class="k-card-title">{{ item.title }}</div>
|
||||
@@ -505,14 +588,12 @@ onUnmounted(() => {
|
||||
<!-- Task specifics -->
|
||||
<div v-if="item.note_type === 'task'" class="k-card-task">
|
||||
<div class="task-badges">
|
||||
<span class="status-badge" :class="`status--${item.status}`">
|
||||
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
|
||||
</span>
|
||||
<span
|
||||
<StatusBadge v-if="item.status" :status="item.status as TaskStatus" compact />
|
||||
<PriorityBadge
|
||||
v-if="item.priority && item.priority !== 'none'"
|
||||
class="priority-badge"
|
||||
:class="`priority--${item.priority}`"
|
||||
>{{ item.priority }}</span>
|
||||
:priority="item.priority as TaskPriority"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-if="item.due_date"
|
||||
@@ -544,6 +625,7 @@ onUnmounted(() => {
|
||||
<span v-if="contentFetching" class="sentinel-loading">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Graph panel -->
|
||||
@@ -574,6 +656,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/dup-report.css" />
|
||||
<style scoped>
|
||||
/* ── Root layout ─────────────────────────────────────────── */
|
||||
.knowledge-root {
|
||||
@@ -606,14 +689,6 @@ onUnmounted(() => {
|
||||
text-decoration: none;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.today-link {
|
||||
color: var(--fs-accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
opacity: 0.85;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.today-link:hover { opacity: 1; }
|
||||
|
||||
/* ── Main layout ─────────────────────────────────────────── */
|
||||
.knowledge-layout {
|
||||
@@ -752,7 +827,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.filter-btn.active .filter-count {
|
||||
background: color-mix(in srgb, var(--fs-accent) 20%, transparent);
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
}
|
||||
.filter-tag { font-size: 0.78rem; }
|
||||
|
||||
@@ -880,6 +955,14 @@ onUnmounted(() => {
|
||||
.badge--note { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: #7A6DA8; }
|
||||
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
|
||||
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
|
||||
/* Snippet and process are NEUTRAL on purpose. Both were unstyled — and the
|
||||
snippet had no label either, so all 90 of them rendered an empty chip in
|
||||
this feed (#3128). Giving them hues would put a third and fourth colour
|
||||
beside KindBadge's warm/cool pair on the same card; a record type that
|
||||
isn't an alarm reads better as plain. Standard body pair, so the contrast
|
||||
is the one the palette already guarantees. */
|
||||
.badge--snippet,
|
||||
.badge--process { background: var(--fs-surface-raised); color: var(--fs-text-secondary); }
|
||||
|
||||
.k-card-body { flex: 1; padding-right: 40px; }
|
||||
.k-card-title {
|
||||
@@ -925,7 +1008,7 @@ onUnmounted(() => {
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--fs-text-secondary) 15%, transparent);
|
||||
color: var(--fs-text-secondary);
|
||||
color: var(--fs-text-secondary-fg);
|
||||
}
|
||||
|
||||
/* ── Task card ──────────────────────────────────────────── */
|
||||
@@ -939,26 +1022,7 @@ onUnmounted(() => {
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.status-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status--todo { background: var(--fs-status-todo-bg); color: var(--fs-status-todo); }
|
||||
.status--in_progress { background: var(--fs-status-in-progress-bg); color: var(--fs-status-in-progress); }
|
||||
.status--done { background: var(--fs-status-done-bg); color: var(--fs-status-done); }
|
||||
.status--cancelled { background: var(--fs-status-todo-bg); color: var(--fs-status-todo); text-decoration: line-through; }
|
||||
|
||||
.priority-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.priority--low { background: var(--fs-priority-low-bg); color: var(--fs-priority-low); }
|
||||
.priority--normal { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); }
|
||||
.priority--high { background: var(--fs-priority-high-bg); color: var(--fs-priority-high); }
|
||||
|
||||
.task-due {
|
||||
font-size: 0.78rem;
|
||||
@@ -1041,57 +1105,6 @@ onUnmounted(() => {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
/* ── Near-duplicate report ──────────────────────────────────────────────────
|
||||
Mirrors SnippetListView's panel so the two reports read as one feature.
|
||||
Scoped styles can't be shared across SFCs; if a third view ever grows this
|
||||
panel, promote the family to components.css and record it (#2464's rule:
|
||||
two-or-more is when a recipe earns the shared sheet). */
|
||||
.dup-panel {
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--fs-surface-hover);
|
||||
}
|
||||
.dup-empty,
|
||||
.dup-head {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.dup-empty { margin-bottom: 0; }
|
||||
.dup-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.dup-members {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1 1 20rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.dup-member {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
||||
color: var(--fs-text-primary);
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dup-member:hover { background: var(--fs-surface-hover); }
|
||||
.dup-score {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* A set someone already ruled on — quiet, not celebratory: it means "skip". */
|
||||
.dup-claimed {
|
||||
font-size: 0.72rem;
|
||||
|
||||
@@ -1,485 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import PaginationBar from "@/components/PaginationBar.vue";
|
||||
import { fmtLogStamp } from "@/utils/dateFormat";
|
||||
|
||||
const toastStore = useToastStore();
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
category: string;
|
||||
user_id: number | null;
|
||||
username: string | null;
|
||||
action: string | null;
|
||||
endpoint: string | null;
|
||||
method: string | null;
|
||||
status_code: number | null;
|
||||
duration_ms: number | null;
|
||||
ip_address: string | null;
|
||||
details: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface LogStats {
|
||||
audit: number;
|
||||
usage: number;
|
||||
error: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const logs = ref<LogEntry[]>([]);
|
||||
const stats = ref<LogStats>({ audit: 0, usage: 0, error: 0, total: 0 });
|
||||
const total = ref(0);
|
||||
const loading = ref(true);
|
||||
const expandedId = ref<number | null>(null);
|
||||
|
||||
// Filters
|
||||
const category = ref("");
|
||||
const search = ref("");
|
||||
const dateFrom = ref("");
|
||||
const dateTo = ref("");
|
||||
const limit = 50;
|
||||
const offset = ref(0);
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchLogs(), fetchStats()]);
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
watch([category, dateFrom, dateTo], () => {
|
||||
offset.value = 0;
|
||||
fetchLogs();
|
||||
});
|
||||
|
||||
watch(search, () => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
offset.value = 0;
|
||||
fetchLogs();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
watch(offset, () => {
|
||||
fetchLogs();
|
||||
});
|
||||
|
||||
async function fetchLogs() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (category.value) params.set("category", category.value);
|
||||
if (search.value) params.set("search", search.value);
|
||||
if (dateFrom.value) params.set("date_from", dateFrom.value);
|
||||
if (dateTo.value) params.set("date_to", dateTo.value);
|
||||
params.set("limit", String(limit));
|
||||
params.set("offset", String(offset.value));
|
||||
|
||||
const data = await apiGet<{ logs: LogEntry[]; total: number }>(
|
||||
`/api/admin/logs?${params}`
|
||||
);
|
||||
logs.value = data.logs;
|
||||
total.value = data.total;
|
||||
} catch {
|
||||
toastStore.show("Failed to load logs", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
stats.value = await apiGet<LogStats>("/api/admin/logs/stats");
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpand(id: number) {
|
||||
expandedId.value = expandedId.value === id ? null : id;
|
||||
}
|
||||
|
||||
function formatDetails(details: string | null): string {
|
||||
if (!details) return "";
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(details), null, 2);
|
||||
} catch {
|
||||
return details;
|
||||
}
|
||||
}
|
||||
|
||||
function displayLabel(entry: LogEntry): string {
|
||||
if (entry.category === "audit" && entry.action) return entry.action;
|
||||
if (entry.endpoint) return entry.endpoint;
|
||||
return "—";
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
category.value = "";
|
||||
search.value = "";
|
||||
dateFrom.value = "";
|
||||
dateTo.value = "";
|
||||
offset.value = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="logs-page">
|
||||
<h1>Application Logs</h1>
|
||||
|
||||
<section class="settings-section stats-section">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-count">{{ stats.total.toLocaleString() }}</span>
|
||||
<span class="stat-label">Total</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-count stat-audit">{{ stats.audit.toLocaleString() }}</span>
|
||||
<span class="stat-label">Audit</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-count stat-usage">{{ stats.usage.toLocaleString() }}</span>
|
||||
<span class="stat-label">Usage</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-count stat-error">{{ stats.error.toLocaleString() }}</span>
|
||||
<span class="stat-label">Error</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Filters</h2>
|
||||
<div class="filter-bar">
|
||||
<select v-model="category" class="filter-select">
|
||||
<option value="">All categories</option>
|
||||
<option value="audit">Audit</option>
|
||||
<option value="usage">Usage</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
class="filter-input"
|
||||
/>
|
||||
<input v-model="dateFrom" type="date" class="filter-date" title="From date" />
|
||||
<input v-model="dateTo" type="date" class="filter-date" title="To date" />
|
||||
<button
|
||||
v-if="category || search || dateFrom || dateTo"
|
||||
class="btn-ghost btn-compact"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<div v-if="loading" class="loading-msg">Loading logs...</div>
|
||||
|
||||
<div v-else-if="logs.length === 0" class="empty-msg">No log entries found.</div>
|
||||
|
||||
<template v-else>
|
||||
<table class="users-table logs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Category</th>
|
||||
<th class="hide-mobile">User</th>
|
||||
<th>Action / Endpoint</th>
|
||||
<th class="hide-mobile">IP</th>
|
||||
<th class="hide-mobile">Status</th>
|
||||
<th class="hide-mobile">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="entry in logs" :key="entry.id">
|
||||
<tr
|
||||
class="log-row"
|
||||
:class="{ 'row-expanded': expandedId === entry.id }"
|
||||
@click="toggleExpand(entry.id)"
|
||||
>
|
||||
<td class="cell-time">{{ fmtLogStamp(entry.created_at) }}</td>
|
||||
<td>
|
||||
<span class="category-badge" :class="'cat-' + entry.category">
|
||||
{{ entry.category }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="hide-mobile cell-user">{{ entry.username || "—" }}</td>
|
||||
<td class="cell-action">
|
||||
<span v-if="entry.method" class="method-tag">{{ entry.method }}</span>
|
||||
{{ displayLabel(entry) }}
|
||||
</td>
|
||||
<td class="hide-mobile cell-ip">{{ entry.ip_address || "—" }}</td>
|
||||
<td class="hide-mobile cell-status">
|
||||
<span v-if="entry.status_code" :class="entry.status_code >= 400 ? 'text-error' : ''">
|
||||
{{ entry.status_code }}
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td class="hide-mobile cell-duration">
|
||||
{{ entry.duration_ms != null ? entry.duration_ms + "ms" : "—" }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedId === entry.id && (entry.details || entry.ip_address)" class="detail-row">
|
||||
<td colspan="7">
|
||||
<div v-if="entry.ip_address" class="detail-ip">IP: {{ entry.ip_address }}</div>
|
||||
<pre v-if="entry.details" class="detail-json">{{ formatDetails(entry.details) }}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<PaginationBar
|
||||
:total="total"
|
||||
:limit="limit"
|
||||
:offset="offset"
|
||||
@update:offset="offset = $event"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logs-page {
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.logs-page h1 {
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
.settings-section {
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.settings-section h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-section {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
.stats-grid {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.stat-count {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.stat-audit {
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.stat-usage {
|
||||
color: var(--fs-success);
|
||||
}
|
||||
.stat-error {
|
||||
color: var(--fs-error);
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-select,
|
||||
.filter-input,
|
||||
.filter-date {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.filter-select {
|
||||
min-width: 140px;
|
||||
}
|
||||
.filter-input {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
.filter-date {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.loading-msg,
|
||||
.empty-msg {
|
||||
text-align: center;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.logs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.logs-table th {
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fs-text-tertiary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.logs-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.logs-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.log-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.log-row:hover {
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.row-expanded {
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.cell-time {
|
||||
white-space: nowrap;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.cell-user {
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.cell-action {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cell-status {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cell-ip {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cell-duration {
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.detail-ip {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.text-error {
|
||||
color: var(--fs-error);
|
||||
}
|
||||
|
||||
/* Category badges */
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
.cat-audit {
|
||||
color: var(--fs-accent);
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
}
|
||||
.cat-usage {
|
||||
color: var(--fs-success);
|
||||
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
|
||||
}
|
||||
.cat-error {
|
||||
color: var(--fs-error);
|
||||
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
|
||||
}
|
||||
|
||||
/* Method tag */
|
||||
.method-tag {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
font-family: monospace;
|
||||
padding: 0.05rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-tertiary);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* Detail row */
|
||||
/* `.detail-row` is deliberately bare: a `<tr>` has nothing to style that its
|
||||
cells don't carry, and the row exists to scope the rule below (#2444). */
|
||||
.detail-row td {
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.detail-json {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
background: var(--fs-surface-page);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
font-size: 0.8rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat-card {
|
||||
min-width: calc(50% - 0.5rem);
|
||||
}
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
.filter-select,
|
||||
.filter-input,
|
||||
.filter-date {
|
||||
width: 100%;
|
||||
}
|
||||
.cell-action {
|
||||
max-width: 160px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -34,6 +34,14 @@ const tags = ref<string[]>([]);
|
||||
const projectId = ref<number | null>(null);
|
||||
const milestoneId = ref<number | null>(null);
|
||||
const noteType = ref<NoteType>("note");
|
||||
|
||||
// The note's own check (milestone 317). Offered only for a plain note: a
|
||||
// task's decay is its status, and a snippet has verify_snippet — the service
|
||||
// refuses both, so the form must not ask for what the save would reject.
|
||||
const verifyWith = ref("");
|
||||
const expiresWhen = ref("");
|
||||
const verifiedAt = ref<string | null>(null);
|
||||
const canCarryCheck = computed(() => noteType.value === "note");
|
||||
const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
const showPreview = ref(false);
|
||||
@@ -198,6 +206,41 @@ let savedTags: string[] = [];
|
||||
let savedProjectId: number | null = null;
|
||||
let savedMilestoneId: number | null = null;
|
||||
let savedNoteType: NoteType = "note";
|
||||
let savedVerifyWith = "";
|
||||
let savedExpiresWhen = "";
|
||||
|
||||
/** The write, in one place. Three call sites (save, create, auto-save) each
|
||||
* spelled this out, so every new field had to be added three times — which is
|
||||
* how one of them ends up not carrying it. */
|
||||
function payload() {
|
||||
return {
|
||||
title: title.value,
|
||||
body: body.value,
|
||||
tags: tags.value,
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
note_type: noteType.value,
|
||||
// "" clears the check: the REST door reads an empty string as NULL
|
||||
// (NULLABLE_NOTE_TEXT), which is how a cleared form input says "remove
|
||||
// this" without needing the MCP door's explicit `clear` list.
|
||||
verify_with: canCarryCheck.value ? verifyWith.value : "",
|
||||
expires_when: canCarryCheck.value ? expiresWhen.value : "",
|
||||
};
|
||||
}
|
||||
|
||||
/** What the form last agreed with the server about — the other half of the
|
||||
* same list, and for the same reason. */
|
||||
function snapshot() {
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedNoteType = noteType.value;
|
||||
savedVerifyWith = verifyWith.value;
|
||||
savedExpiresWhen = expiresWhen.value;
|
||||
dirty.value = false;
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
dirty.value =
|
||||
@@ -206,7 +249,9 @@ function markDirty() {
|
||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||
projectId.value !== savedProjectId ||
|
||||
milestoneId.value !== savedMilestoneId ||
|
||||
noteType.value !== savedNoteType;
|
||||
noteType.value !== savedNoteType ||
|
||||
verifyWith.value !== savedVerifyWith ||
|
||||
expiresWhen.value !== savedExpiresWhen;
|
||||
}
|
||||
|
||||
function onBodyUpdate(newVal: string) {
|
||||
@@ -224,12 +269,10 @@ onMounted(async () => {
|
||||
projectId.value = store.currentNote.project_id ?? null;
|
||||
milestoneId.value = store.currentNote.milestone_id ?? null;
|
||||
noteType.value = (store.currentNote.note_type as NoteType) || "note";
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedNoteType = noteType.value;
|
||||
verifyWith.value = store.currentNote.verify_with || "";
|
||||
expiresWhen.value = store.currentNote.expires_when || "";
|
||||
verifiedAt.value = store.currentNote.verified_at ?? null;
|
||||
snapshot();
|
||||
}
|
||||
} else {
|
||||
// New note: read type from query param
|
||||
@@ -260,31 +303,11 @@ async function save() {
|
||||
const finalBody = body.value;
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await store.updateNote(noteId.value!, {
|
||||
title: title.value,
|
||||
body: finalBody,
|
||||
tags: tags.value,
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
note_type: noteType.value,
|
||||
});
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedNoteType = noteType.value;
|
||||
dirty.value = false;
|
||||
await store.updateNote(noteId.value!, { ...payload(), body: finalBody });
|
||||
snapshot();
|
||||
toast.show("Note saved");
|
||||
} else {
|
||||
const note = await store.createNote({
|
||||
title: title.value,
|
||||
body: finalBody,
|
||||
tags: tags.value,
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
note_type: noteType.value,
|
||||
});
|
||||
const note = await store.createNote({ ...payload(), body: finalBody });
|
||||
dirty.value = false;
|
||||
toast.show("Note created");
|
||||
router.push(`/notes/${note.id}`);
|
||||
@@ -321,18 +344,8 @@ async function doAutoSave() {
|
||||
saving.value = true;
|
||||
const finalBody = body.value;
|
||||
try {
|
||||
await store.updateNote(noteId.value!, {
|
||||
title: title.value, body: finalBody, tags: tags.value,
|
||||
project_id: projectId.value, milestone_id: milestoneId.value,
|
||||
note_type: noteType.value,
|
||||
});
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedNoteType = noteType.value;
|
||||
dirty.value = false;
|
||||
await store.updateNote(noteId.value!, { ...payload(), body: finalBody });
|
||||
snapshot();
|
||||
toast.show("Auto-saved");
|
||||
} catch {
|
||||
// Silent
|
||||
@@ -496,6 +509,41 @@ onUnmounted(() => assist.clearSelection());
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- The note's own check (milestone 317). Shown only for a plain
|
||||
note: the service refuses a check on a task or a snippet, so
|
||||
offering the fields there would be a form whose save fails. -->
|
||||
<template v-if="canCarryCheck">
|
||||
<div class="sb-field">
|
||||
<div class="sb-label-row">
|
||||
<span class="sb-label">Check</span>
|
||||
<span v-if="verifyWith" class="check-age" :class="{ unchecked: !verifiedAt }">
|
||||
{{ verifiedAt ? `checked ${verifiedAt.slice(0, 10)}` : "never checked" }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Phrased as the question that decides, not as a field name.
|
||||
"Verify with" would get filled in on every note; "could this
|
||||
become false without anyone editing it?" gets filled in on
|
||||
the few that can. -->
|
||||
<textarea
|
||||
v-model="verifyWith"
|
||||
class="sb-textarea"
|
||||
rows="2"
|
||||
placeholder="How would someone check this is still true? Leave empty unless this note could become false without anyone editing it."
|
||||
@input="markDirty"
|
||||
></textarea>
|
||||
</div>
|
||||
<div v-if="verifyWith" class="sb-field">
|
||||
<label class="sb-label">Ends when</label>
|
||||
<textarea
|
||||
v-model="expiresWhen"
|
||||
class="sb-textarea"
|
||||
rows="2"
|
||||
placeholder="What state ends it? A state, not a date — “when the forge numbers runs per workflow”, not “in six months”."
|
||||
@input="markDirty"
|
||||
></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Link Suggestions -->
|
||||
<div v-if="linkSuggestions.length > 0" class="sb-field link-suggest-field">
|
||||
<div class="sb-label-row">
|
||||
@@ -626,16 +674,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.body-tabs-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
.editor-tabs {
|
||||
display: inline-flex;
|
||||
background: var(--fs-surface-page);
|
||||
@@ -673,28 +711,11 @@ onUnmounted(() => assist.clearSelection());
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.body-editor-wrap {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.stream-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.stream-preview {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.75rem;
|
||||
background: var(--fs-surface-raised);
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.main-diff {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Right sidebar */
|
||||
.note-sidebar {
|
||||
width: 280px;
|
||||
@@ -705,7 +726,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sb-select, .sb-input {
|
||||
.sb-select, .sb-input, .sb-textarea {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
@@ -717,16 +738,26 @@ onUnmounted(() => assist.clearSelection());
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.sb-select:focus, .sb-input:focus {
|
||||
.sb-select:focus, .sb-input:focus, .sb-textarea:focus {
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
|
||||
/* Tag suggest row inside sidebar */
|
||||
.tag-suggest-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
.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 */
|
||||
@@ -798,14 +829,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.assist-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ── Process editor ─────────────────────────────────────── */
|
||||
.ef-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
|
||||
@@ -416,7 +416,7 @@ async function convertToTask() {
|
||||
}
|
||||
.badge-note {
|
||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
|
||||
}
|
||||
.badge-task {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
|
||||
import ProjectStatusBadge from "@/components/ProjectStatusBadge.vue";
|
||||
import { emptyChoices, type InceptionChoices } from "@/api/inception";
|
||||
import InceptionCard from "@/components/InceptionCard.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
@@ -109,13 +110,6 @@ async function createProject() {
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: Project["status"]): string {
|
||||
if (status === "active") return "Active";
|
||||
if (status === "paused") return "Paused";
|
||||
if (status === "completed") return "Completed";
|
||||
if (status === "archived") return "Archived";
|
||||
return status;
|
||||
}
|
||||
|
||||
function truncate(text: string | null, max = 120): string {
|
||||
if (!text) return "";
|
||||
@@ -171,7 +165,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="projects-list">
|
||||
<main class="page-container">
|
||||
<div class="page-header">
|
||||
<h1>Projects</h1>
|
||||
<button class="btn-primary" @click="openNewProjectModal">+ New Project</button>
|
||||
@@ -210,9 +204,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
>
|
||||
<div class="card-header">
|
||||
<span class="project-title">{{ project.title }}</span>
|
||||
<span
|
||||
:class="['status-badge', `status-${project.status}`]"
|
||||
>{{ statusLabel(project.status) }}</span>
|
||||
<ProjectStatusBadge :status="project.status" />
|
||||
</div>
|
||||
<p v-if="project.goal" class="project-goal">
|
||||
<span class="field-label">Goal:</span> {{ truncate(project.goal) }}
|
||||
@@ -336,22 +328,6 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.projects-list {
|
||||
max-width: var(--fs-layout-page-max);
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--fs-layout-page-pad);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Moss action-primary per Hybrid — list-view utility action,
|
||||
not a brand moment. Empty-state .empty-action below keeps accent. */
|
||||
@@ -382,21 +358,12 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
border-bottom-color: var(--fs-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-msg,
|
||||
.error-msg {
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--fs-error);
|
||||
}
|
||||
|
||||
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--fs-text-tertiary); }
|
||||
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.3; }
|
||||
.empty-title { font-size: 1rem; font-weight: 500; color: var(--fs-text-secondary); margin: 0 0 0.35rem; }
|
||||
.empty-sub { font-size: 0.85rem; margin: 0 0 1rem; }
|
||||
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--fs-action-primary); border-radius: var(--fs-radius-sm); color: var(--fs-action-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
|
||||
.empty-action:hover { background: var(--fs-action-primary); color: var(--fs-text-on-action); }
|
||||
|
||||
@@ -455,28 +422,6 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-active {
|
||||
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
|
||||
color: var(--fs-success);
|
||||
}
|
||||
.status-completed {
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.status-archived {
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.project-goal {
|
||||
font-size: 0.875rem;
|
||||
@@ -599,9 +544,6 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.required {
|
||||
color: var(--fs-error);
|
||||
}
|
||||
.modal-input,
|
||||
.modal-textarea {
|
||||
padding: 0.45rem 0.7rem;
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { apiGet, apiPatch, apiDelete, apiPost, apiPut } from "@/api/client";
|
||||
import { apiGet, apiPatch, apiDelete, apiPost, apiPut, apiErrorMessage } from "@/api/client";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import ShareDialog from "@/components/ShareDialog.vue";
|
||||
import KindBadge from "@/components/KindBadge.vue";
|
||||
import ProjectStatusBadge from "@/components/ProjectStatusBadge.vue";
|
||||
import type { TaskKind } from "@/types/note";
|
||||
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
|
||||
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
||||
import SystemsSection from "@/components/SystemsSection.vue";
|
||||
@@ -74,6 +77,7 @@ interface NoteItem {
|
||||
due_date?: string | null;
|
||||
updated_at: string;
|
||||
milestone_id?: number | null;
|
||||
task_kind?: TaskKind;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
@@ -543,8 +547,7 @@ async function saveForgePin() {
|
||||
if (project.value) project.value.forge_connection_id = forgePin.value;
|
||||
await loadCoverage();
|
||||
} catch (e) {
|
||||
const body = (e as { body?: { error?: string } }).body;
|
||||
toast.show(body?.error || "Failed to change the project's forge", "error");
|
||||
toast.show(apiErrorMessage(e, "Failed to change the project's forge"), "error");
|
||||
forgePin.value = project.value?.forge_connection_id ?? null;
|
||||
} finally {
|
||||
savingForgePin.value = false;
|
||||
@@ -641,7 +644,7 @@ async function confirmDelete() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="project-view">
|
||||
<main class="page-container">
|
||||
|
||||
<!-- Nav bar -->
|
||||
<div class="page-header">
|
||||
@@ -694,9 +697,7 @@ async function confirmDelete() {
|
||||
<div class="project-header">
|
||||
<div class="title-row">
|
||||
<input v-model="editTitle" type="text" class="project-title-input" placeholder="Project title" />
|
||||
<span :class="['status-badge', `status-${project.status}`]">
|
||||
{{ project.status.charAt(0).toUpperCase() + project.status.slice(1) }}
|
||||
</span>
|
||||
<ProjectStatusBadge :status="project.status" />
|
||||
</div>
|
||||
<p v-if="project.goal" class="project-goal">{{ project.goal }}</p>
|
||||
<p v-if="project.summary?.last_activity" class="project-activity">
|
||||
@@ -874,15 +875,15 @@ async function confirmDelete() {
|
||||
paragraph in practice — this one showed as "Maintain Scribe as
|
||||
the reliabl" and gave no way to read the rest without arrowing
|
||||
through it. -->
|
||||
<textarea v-model="editGoal" class="edit-textarea" rows="4" placeholder="What are you trying to achieve?"></textarea>
|
||||
<textarea v-model="editGoal" class="fs-input edit-textarea" rows="4" placeholder="What are you trying to achieve?"></textarea>
|
||||
</div>
|
||||
<div class="edit-field">
|
||||
<label class="edit-label">Description</label>
|
||||
<textarea v-model="editDescription" class="edit-textarea" rows="6" placeholder="Optional description..."></textarea>
|
||||
<textarea v-model="editDescription" class="fs-input edit-textarea" rows="6" placeholder="Optional description..."></textarea>
|
||||
</div>
|
||||
<div class="edit-field">
|
||||
<label class="edit-label">Status</label>
|
||||
<select v-model="editStatus" class="edit-select">
|
||||
<select v-model="editStatus" class="fs-input edit-select">
|
||||
<option value="active">Active</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="completed">Completed</option>
|
||||
@@ -891,7 +892,7 @@ async function confirmDelete() {
|
||||
</div>
|
||||
<div v-if="designSystems.length" class="edit-field">
|
||||
<label class="edit-label" for="project-design-system">Design system</label>
|
||||
<select id="project-design-system" v-model="editDesignSystemId" class="edit-select">
|
||||
<select id="project-design-system" v-model="editDesignSystemId" class="fs-input edit-select">
|
||||
<option :value="null">None</option>
|
||||
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
|
||||
</select>
|
||||
@@ -1047,6 +1048,7 @@ async function confirmDelete() {
|
||||
:class="['task-card', `pri-${task.priority || 'none'}`]"
|
||||
>
|
||||
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<div class="task-card-footer">
|
||||
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
|
||||
@@ -1078,6 +1080,7 @@ async function confirmDelete() {
|
||||
:class="['task-card', `pri-${task.priority || 'none'}`]"
|
||||
>
|
||||
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<div class="task-card-footer">
|
||||
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
|
||||
@@ -1109,6 +1112,7 @@ async function confirmDelete() {
|
||||
class="task-card task-card-done"
|
||||
>
|
||||
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<div v-if="task.due_date" class="task-meta">
|
||||
<span class="due-date">{{ task.due_date }}</span>
|
||||
</div>
|
||||
@@ -1202,19 +1206,9 @@ async function confirmDelete() {
|
||||
|
||||
<style scoped>
|
||||
/* ── Layout ─────────────────────────────────────────────────── */
|
||||
.project-view {
|
||||
max-width: var(--fs-layout-page-max);
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--fs-layout-page-pad);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* ── Nav bar ─────────────────────────────────────────────────── */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 1.5rem; /* roomier than the shared recipe */
|
||||
}
|
||||
.page-header-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.plan-title-input {
|
||||
@@ -1245,19 +1239,6 @@ async function confirmDelete() {
|
||||
.project-title-input:focus { border-bottom-color: var(--fs-accent); }
|
||||
.project-title-input::placeholder { color: var(--fs-text-tertiary); font-weight: 400; }
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-active { background: color-mix(in srgb, var(--fs-success) 14%, transparent); color: var(--fs-success); border: 1px solid color-mix(in srgb, var(--fs-success) 30%, transparent); }
|
||||
.status-paused { background: color-mix(in srgb, var(--fs-warning) 14%, transparent); color: var(--fs-warning); border: 1px solid color-mix(in srgb, var(--fs-warning) 30%, transparent); }
|
||||
.status-completed { background: color-mix(in srgb, var(--fs-accent) 14%, transparent); color: var(--fs-accent); border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent); }
|
||||
.status-archived { background: color-mix(in srgb, var(--fs-text-tertiary) 14%, transparent); color: var(--fs-text-tertiary); border: 1px solid color-mix(in srgb, var(--fs-text-tertiary) 30%, transparent); }
|
||||
|
||||
.project-goal {
|
||||
font-size: 1rem;
|
||||
@@ -1306,8 +1287,8 @@ async function confirmDelete() {
|
||||
|
||||
.stat-todo { background: color-mix(in srgb, var(--fs-text-tertiary) 8%, transparent); color: var(--fs-text-secondary); border-color: var(--fs-border-color); }
|
||||
.stat-inprogress { background: color-mix(in srgb, #3b82f6 10%, transparent); color: #3b82f6; border-color: color-mix(in srgb, #3b82f6 28%, transparent); }
|
||||
.stat-done { background: color-mix(in srgb, var(--fs-success) 10%, transparent); color: var(--fs-success); border-color: color-mix(in srgb, var(--fs-success) 28%, transparent); }
|
||||
.stat-notes { background: color-mix(in srgb, var(--fs-accent) 8%, transparent); color: var(--fs-accent); border-color: color-mix(in srgb, var(--fs-accent) 22%, transparent); }
|
||||
.stat-done { background: color-mix(in srgb, var(--fs-success) 10%, transparent); color: var(--fs-success-fg); border-color: color-mix(in srgb, var(--fs-success) 28%, transparent); }
|
||||
.stat-notes { background: color-mix(in srgb, var(--fs-accent) 8%, transparent); color: var(--fs-accent-fg); border-color: color-mix(in srgb, var(--fs-accent) 22%, transparent); }
|
||||
|
||||
/* ── Pattern-library coverage card ───────────────────────────── */
|
||||
.coverage-card {
|
||||
@@ -1409,7 +1390,7 @@ async function confirmDelete() {
|
||||
/* `minmax(0, 1fr)`, not `1fr`. A bare `1fr` track has an AUTO minimum, so it
|
||||
cannot shrink below its content — one wide descendant anywhere in the
|
||||
content column widens the whole column past the grid, and everything inside
|
||||
it then overflows the page and gets cut by `.project-view`'s
|
||||
it then overflows the page and gets cut by `.page-container`'s
|
||||
`overflow-x: clip`.
|
||||
This is the same property the header nav relies on and wants (neither side
|
||||
squeezed under its content); here it is exactly wrong, because the column
|
||||
@@ -1453,18 +1434,10 @@ async function confirmDelete() {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.edit-input, .edit-textarea, .edit-select {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.edit-input:focus, .edit-textarea:focus, .edit-select:focus { outline: none; border-color: var(--fs-accent); }
|
||||
/* The input itself is the .fs-input canon (components.css); only the
|
||||
layout remainder lives here. */
|
||||
.edit-textarea,
|
||||
.edit-select { box-sizing: border-box; width: 100%; }
|
||||
.edit-textarea { resize: vertical; }
|
||||
|
||||
/* Save panel: Moss action-primary per Hybrid rule */
|
||||
@@ -1498,7 +1471,7 @@ async function confirmDelete() {
|
||||
.tab-btn.active .tab-count {
|
||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
}
|
||||
|
||||
/* ── Tasks view ──────────────────────────────────────────────── */
|
||||
@@ -1734,7 +1707,7 @@ async function confirmDelete() {
|
||||
border-radius: 3px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.col-add-btn:hover { color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
|
||||
.col-add-btn:hover { color: var(--fs-accent-fg); background: color-mix(in srgb, var(--fs-accent) 10%, transparent); }
|
||||
|
||||
.kanban-cards { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
|
||||
@@ -1833,7 +1806,7 @@ async function confirmDelete() {
|
||||
.note-title { font-weight: 500; min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.note-date { font-size: 0.75rem; color: var(--fs-text-tertiary); flex-shrink: 0; }
|
||||
|
||||
.empty-msg { color: var(--fs-text-tertiary); font-size: 0.875rem; text-align: center; padding: 1rem; }
|
||||
.empty-msg { text-align: center; padding: 1rem; } /* remainder over the shared recipe */
|
||||
/* Deliberately NOT styled like .empty-msg: "no tasks" and "the tasks did not
|
||||
load" look identical to a user, and conflating them is what let a silent
|
||||
failure read as an empty project. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import RulebookListPane from "@/components/rules/RulebookListPane.vue";
|
||||
import RulebookDetailPane from "@/components/rules/RulebookDetailPane.vue";
|
||||
import RuleListPane from "@/components/rules/RuleListPane.vue";
|
||||
import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue";
|
||||
import RuleSweepPane from "@/components/rules/RuleSweepPane.vue";
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const route = useRoute();
|
||||
@@ -15,6 +16,7 @@ const selectedRulebookId = ref<number | null>(null);
|
||||
const selectedTopicId = ref<number | null>(null);
|
||||
const editingRuleId = ref<number | null>(null);
|
||||
const creatingRuleForTopic = ref<number | null>(null);
|
||||
const sweepActive = ref(false);
|
||||
|
||||
function syncFromRoute() {
|
||||
const rb = route.query.rb ? Number(route.query.rb) : null;
|
||||
@@ -23,9 +25,20 @@ function syncFromRoute() {
|
||||
selectedRulebookId.value = rb;
|
||||
selectedTopicId.value = topic;
|
||||
editingRuleId.value = rule;
|
||||
sweepActive.value = route.query.view === "due";
|
||||
}
|
||||
|
||||
function selectSweep() {
|
||||
sweepActive.value = true;
|
||||
// Keeps ?rule=… so the editor survives the mode switch, and drops the
|
||||
// rulebook/topic selection the sweep does not use.
|
||||
const { rb, topic, ...rest } = route.query;
|
||||
void rb; void topic;
|
||||
router.replace({ query: { ...rest, view: "due" } });
|
||||
}
|
||||
|
||||
function selectRulebook(id: number) {
|
||||
sweepActive.value = false;
|
||||
selectedRulebookId.value = id;
|
||||
selectedTopicId.value = null;
|
||||
router.replace({ query: { rb: String(id) } });
|
||||
@@ -70,10 +83,13 @@ watch(() => route.query, syncFromRoute);
|
||||
<RulebookListPane
|
||||
:rulebooks="store.rulebooks"
|
||||
:selected-id="selectedRulebookId"
|
||||
:sweep-active="sweepActive"
|
||||
@select="selectRulebook"
|
||||
@select-sweep="selectSweep"
|
||||
/>
|
||||
<RuleSweepPane v-if="sweepActive" class="sweep-span" @open-rule="openRule" />
|
||||
<RulebookDetailPane
|
||||
v-if="selectedRulebookId !== null"
|
||||
v-else-if="selectedRulebookId !== null"
|
||||
:rulebook-id="selectedRulebookId"
|
||||
:topics="store.topicsByRulebook[selectedRulebookId] || []"
|
||||
:selected-topic-id="selectedTopicId"
|
||||
@@ -83,13 +99,13 @@ watch(() => route.query, syncFromRoute);
|
||||
<p>Select a rulebook to view its topics.</p>
|
||||
</div>
|
||||
<RuleListPane
|
||||
v-if="selectedTopicId !== null"
|
||||
v-if="!sweepActive && selectedTopicId !== null"
|
||||
:topic-id="selectedTopicId"
|
||||
:rules="store.rulesByTopic[selectedTopicId] || []"
|
||||
@open-rule="openRule"
|
||||
@create-rule="startCreatingRule"
|
||||
/>
|
||||
<div v-else class="pane empty">
|
||||
<div v-else-if="!sweepActive" class="pane empty">
|
||||
<p>Select a topic to view its rules.</p>
|
||||
</div>
|
||||
<RuleEditorSlideOver
|
||||
@@ -109,6 +125,9 @@ watch(() => route.query, syncFromRoute);
|
||||
gap: 1px;
|
||||
background: var(--fs-border-color);
|
||||
}
|
||||
/* The sweep is cross-cutting, so it takes the width the rulebook + topic
|
||||
panes would have used rather than being squeezed into one column. */
|
||||
.sweep-span { grid-column: 2 / -1; }
|
||||
.pane.empty {
|
||||
background: var(--fs-surface-hover);
|
||||
padding: 1rem;
|
||||
|
||||
+376
-526
File diff suppressed because it is too large
Load Diff
@@ -98,7 +98,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 2rem; /* roomier than the shared recipe */
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@@ -242,13 +242,11 @@ onMounted(async () => {
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.perm-viewer { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary); }
|
||||
.perm-editor { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent); }
|
||||
.perm-admin { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning); }
|
||||
.perm-viewer { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary-fg); }
|
||||
.perm-editor { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent-fg); }
|
||||
.perm-admin { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning-fg); }
|
||||
|
||||
.empty-msg {
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.88rem;
|
||||
margin: 0;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
@@ -220,14 +220,8 @@ async function confirmDelete() {
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.state-msg {
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.state-msg,
|
||||
.error-msg {
|
||||
color: var(--fs-error);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@@ -294,7 +288,7 @@ async function confirmDelete() {
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: 0.82rem;
|
||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
padding: 0.08rem 0.35rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
word-break: break-all;
|
||||
|
||||
@@ -227,7 +227,7 @@ function cancel() {
|
||||
ref="nameRef"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="input mono"
|
||||
class="fs-input input mono"
|
||||
placeholder="useDebouncedRef"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
@@ -239,7 +239,7 @@ function cancel() {
|
||||
id="sn-when"
|
||||
v-model="form.when_to_use"
|
||||
type="text"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
placeholder="Debounce a reactive ref that updates too often"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
@@ -253,7 +253,7 @@ function cancel() {
|
||||
id="sn-lang"
|
||||
v-model="form.language"
|
||||
type="text"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
placeholder="typescript"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
@@ -264,7 +264,7 @@ function cancel() {
|
||||
id="sn-sig"
|
||||
v-model="form.signature"
|
||||
type="text"
|
||||
class="input mono"
|
||||
class="fs-input input mono"
|
||||
placeholder="useDebouncedRef(value, ms)"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
@@ -277,9 +277,9 @@ function cancel() {
|
||||
<span class="hint-inline">— where the reference implementation(s) live; a merged snippet keeps every call site</span>
|
||||
</legend>
|
||||
<div v-for="(loc, i) in locations" :key="i" class="loc-row">
|
||||
<input v-model="loc.repo" type="text" class="input mono" placeholder="repo" aria-label="Repo" @keydown.escape="cancel" />
|
||||
<input v-model="loc.path" type="text" class="input mono" placeholder="path" aria-label="Path" @keydown.escape="cancel" />
|
||||
<input v-model="loc.symbol" type="text" class="input mono" placeholder="symbol" aria-label="Symbol" @keydown.escape="cancel" />
|
||||
<input v-model="loc.repo" type="text" class="fs-input input mono" placeholder="repo" aria-label="Repo" @keydown.escape="cancel" />
|
||||
<input v-model="loc.path" type="text" class="fs-input input mono" placeholder="path" aria-label="Path" @keydown.escape="cancel" />
|
||||
<input v-model="loc.symbol" type="text" class="fs-input input mono" placeholder="symbol" aria-label="Symbol" @keydown.escape="cancel" />
|
||||
<button
|
||||
type="button"
|
||||
class="loc-remove"
|
||||
@@ -296,7 +296,7 @@ function cancel() {
|
||||
<textarea
|
||||
id="sn-code"
|
||||
v-model="form.code"
|
||||
class="input mono code-area"
|
||||
class="fs-input input mono code-area"
|
||||
rows="14"
|
||||
spellcheck="false"
|
||||
placeholder="Paste the reusable implementation…"
|
||||
@@ -309,7 +309,7 @@ function cancel() {
|
||||
id="sn-tags"
|
||||
v-model="tagsText"
|
||||
type="text"
|
||||
class="input"
|
||||
class="fs-input input"
|
||||
placeholder="composable, ui (comma-separated)"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
@@ -383,15 +383,6 @@ function cancel() {
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
|
||||
.state-msg {
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--fs-error);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -408,18 +399,12 @@ function cancel() {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.field-row.three {
|
||||
grid-template-columns: 1fr 1.4fr 1fr;
|
||||
}
|
||||
.field label,
|
||||
.location-set legend {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
.required {
|
||||
color: var(--fs-error);
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
@@ -430,21 +415,10 @@ function cancel() {
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* remainder over .fs-input (components.css, canon #2336; m302) */
|
||||
.input {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--fs-font-mono);
|
||||
@@ -574,8 +548,7 @@ function cancel() {
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.field-row,
|
||||
.field-row.three {
|
||||
.field-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type SnippetListItem,
|
||||
} from "@/api/snippets";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import UsageBadge from "@/components/UsageBadge.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToastStore();
|
||||
@@ -198,23 +199,6 @@ function languageOf(tags: string[]): string {
|
||||
return tags.find((t) => t && t !== "snippet") ?? "";
|
||||
}
|
||||
|
||||
/** A snippet that has been offered repeatedly and never opened. The threshold
|
||||
* is 3 rather than 1 because one or two surfacings is noise — the record may
|
||||
* simply not have come up in a relevant context yet. */
|
||||
function isDeadWeight(s: SnippetListItem): boolean {
|
||||
const u = s.usage;
|
||||
return !!u && u.pull_count === 0 && u.surfaced_count >= 3;
|
||||
}
|
||||
|
||||
/** Short badge text, or "" to render nothing. A record nobody has surfaced yet
|
||||
* gets no badge at all: "0 / 0" would read as a verdict when it's an absence
|
||||
* of evidence. */
|
||||
function usageBadge(s: SnippetListItem): string {
|
||||
const u = s.usage;
|
||||
if (!u || u.surfaced_count === 0) return "";
|
||||
return `${u.pull_count}/${u.surfaced_count} used`;
|
||||
}
|
||||
|
||||
/** Short label for the drift verdict, or "" when there's nothing to say.
|
||||
* An expired verdict is reported as "unchecked" whatever it used to say —
|
||||
* it was about code that is no longer in the record. */
|
||||
@@ -254,26 +238,17 @@ function driftTitle(s: SnippetListItem): string {
|
||||
return v.detail ? `${when}: ${what}. ${v.detail}` : `${when}: ${what}.`;
|
||||
}
|
||||
|
||||
function usageTitle(s: SnippetListItem): string {
|
||||
const u = s.usage;
|
||||
if (!u) return "";
|
||||
const last = u.last_pulled_at
|
||||
? `Last opened ${new Date(u.last_pulled_at).toLocaleDateString()}.`
|
||||
: "Never opened.";
|
||||
const verdict = isDeadWeight(s)
|
||||
? " Offered repeatedly without ever being opened — consider rewriting its" +
|
||||
" “when to reach for it” so it says when, or deleting it. It takes a slot" +
|
||||
" in every future auto-inject menu."
|
||||
: "";
|
||||
return (
|
||||
`Surfaced to an agent ${u.surfaced_count}×, opened in full ` +
|
||||
`${u.pull_count}×. ${last}${verdict}`
|
||||
);
|
||||
}
|
||||
/** The dead-weight nudge for a SNIPPET, passed to the shared badge. Kept here
|
||||
* rather than inside the component because the remedy is kind-specific — a
|
||||
* rule in the same position gets different advice (milestone 333 step 5). */
|
||||
const SNIPPET_DEAD_WEIGHT =
|
||||
"Offered repeatedly without ever being opened — consider rewriting its " +
|
||||
"“when to reach for it” so it says when, or deleting it. It takes a slot " +
|
||||
"in every future auto-inject menu.";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="snippets-list">
|
||||
<main class="page-container">
|
||||
<div class="page-header">
|
||||
<h1>Snippets</h1>
|
||||
<div class="header-actions">
|
||||
@@ -456,14 +431,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
<span v-if="driftBadge(s)" class="drift-tag" :title="driftTitle(s)">
|
||||
{{ driftBadge(s) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="usageBadge(s)"
|
||||
class="usage-tag"
|
||||
:class="{ 'usage-dead': isDeadWeight(s) }"
|
||||
:title="usageTitle(s)"
|
||||
>
|
||||
{{ usageBadge(s) }}
|
||||
</span>
|
||||
<UsageBadge :usage="s.usage" :dead-weight-advice="SNIPPET_DEAD_WEIGHT" />
|
||||
<span v-if="s.shared" class="shared-tag" :title="`Shared by ${s.owner ?? 'another user'} — a suggestion, not your own record`">
|
||||
by {{ s.owner ?? "another user" }}
|
||||
</span>
|
||||
@@ -517,22 +485,10 @@ function usageTitle(s: SnippetListItem): string {
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/dup-report.css" />
|
||||
<style scoped>
|
||||
.snippets-list {
|
||||
max-width: var(--fs-layout-page-max);
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--fs-layout-page-pad);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
margin-bottom: 0.35rem; /* tighter than the shared recipe: .page-sub follows */
|
||||
}
|
||||
.page-sub {
|
||||
margin: 0 0 1.25rem;
|
||||
@@ -622,8 +578,6 @@ function usageTitle(s: SnippetListItem): string {
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: var(--fs-error);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@@ -638,15 +592,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-secondary);
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
.empty-sub {
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 1rem;
|
||||
max-width: 44ch;
|
||||
margin-inline: auto;
|
||||
line-height: 1.5;
|
||||
@@ -732,7 +678,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
color: var(--fs-accent);
|
||||
color: var(--fs-accent-fg);
|
||||
}
|
||||
|
||||
.snippet-when {
|
||||
@@ -761,60 +707,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* Near-duplicate report */
|
||||
.dup-panel {
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--fs-surface-hover);
|
||||
}
|
||||
|
||||
.dup-empty,
|
||||
.dup-head {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
.dup-empty {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dup-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
.dup-members {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1 1 20rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dup-member {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
||||
/* Long snippet names must not push the row into a horizontal scroll. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dup-score {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
color: var(--fs-text-tertiary-fg);
|
||||
}
|
||||
|
||||
.dup-action {
|
||||
@@ -829,24 +722,7 @@ function usageTitle(s: SnippetListItem): string {
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
|
||||
color: var(--fs-error);
|
||||
}
|
||||
|
||||
.usage-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
|
||||
than the danger one, because the record isn't broken, just unearned. */
|
||||
.usage-tag.usage-dead {
|
||||
background: color-mix(in srgb, var(--fs-warning) 18%, transparent);
|
||||
color: var(--fs-warning);
|
||||
color: var(--fs-error-fg);
|
||||
}
|
||||
|
||||
/* Header + select-mode */
|
||||
|
||||
@@ -578,6 +578,7 @@ useEditorGuards(dirty, save);
|
||||
<select v-model="kind" @change="markDirty" class="sb-select">
|
||||
<option value="work">Work</option>
|
||||
<option value="issue">Issue</option>
|
||||
<option value="spike">Spike</option>
|
||||
<!-- 'plan' is retired (plans are milestones via start_planning);
|
||||
offered only so legacy plan-tasks display their kind. -->
|
||||
<option v-if="kind === 'plan'" value="plan">Plan (legacy)</option>
|
||||
@@ -803,7 +804,8 @@ useEditorGuards(dirty, save);
|
||||
max-width: 1600px;
|
||||
}
|
||||
|
||||
/* Replace .editor-body for task editor */
|
||||
/* The task editor's own body row. It began as a replacement for the shared
|
||||
.editor-body, which nothing used afterwards and has since been deleted. */
|
||||
.task-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -823,16 +825,6 @@ useEditorGuards(dirty, save);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.body-tabs-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
|
||||
/* .task-main is a flex column; without flex-shrink: 0, long body content
|
||||
gets squeezed back to min-height and overflows visibly on top of siblings. */
|
||||
.body-editor-wrap,
|
||||
@@ -840,10 +832,6 @@ useEditorGuards(dirty, save);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.body-editor-wrap {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
:deep(.preview-pane) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -949,18 +937,6 @@ useEditorGuards(dirty, save);
|
||||
font-family: inherit;
|
||||
}
|
||||
.subtask-input:focus { outline: none; border-color: var(--fs-accent); }
|
||||
.stream-preview {
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
padding: 0.75rem;
|
||||
background: var(--fs-surface-raised);
|
||||
min-height: 200px;
|
||||
}
|
||||
.main-diff {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Systems multi-select (in sidebar) */
|
||||
.sb-systems { display: flex; flex-direction: column; gap: 0.25rem; max-height: 160px; overflow-y: auto; }
|
||||
.sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--fs-text-primary); cursor: pointer; }
|
||||
@@ -973,26 +949,11 @@ useEditorGuards(dirty, save);
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.assist-section-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--fs-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.assist-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Tag suggest row inside sidebar */
|
||||
.tag-suggest-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Lifecycle timestamps */
|
||||
.sb-timestamps {
|
||||
display: flex;
|
||||
|
||||
@@ -1,767 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import { useNotesStore } from "@/stores/notes";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
import { apiPost, apiGet } from "@/api/client";
|
||||
import type { Note } from "@/types/note";
|
||||
import type { TaskStatus } from "@/types/task";
|
||||
import StatusBadge from "@/components/StatusBadge.vue";
|
||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import TableOfContents from "@/components/TableOfContents.vue";
|
||||
import ShareDialog from "@/components/ShareDialog.vue";
|
||||
import { Clock, Pencil, Link as LinkIcon } from "lucide-vue-next";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useTasksStore();
|
||||
const notesStore = useNotesStore();
|
||||
const backlinks = ref<{ type: string; id: number; title: string }[]>([]);
|
||||
const converting = ref(false);
|
||||
const showShare = ref(false);
|
||||
|
||||
// Context enrichment
|
||||
const projectTitle = ref<string | null>(null);
|
||||
const milestoneName = ref<string | null>(null);
|
||||
const subTasks = ref<Note[]>([]);
|
||||
|
||||
const taskId = computed(() => Number(route.params.id));
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
function cycleSubTaskStatus(subTask: Note) {
|
||||
if (!subTask.status) return;
|
||||
const next = statusCycle[subTask.status as TaskStatus];
|
||||
store.patchStatus(subTask.id, next).then(() => {
|
||||
const idx = subTasks.value.findIndex((t) => t.id === subTask.id);
|
||||
if (idx !== -1) subTasks.value[idx] = { ...subTasks.value[idx], status: next };
|
||||
});
|
||||
}
|
||||
|
||||
async function loadContext(task: Note) {
|
||||
projectTitle.value = null;
|
||||
milestoneName.value = null;
|
||||
subTasks.value = [];
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
if (task.project_id) {
|
||||
promises.push(
|
||||
apiGet<any>(`/api/projects/${task.project_id}`).then((data) => {
|
||||
projectTitle.value = data.title ?? null;
|
||||
if (task.milestone_id && data.summary?.milestone_summary) {
|
||||
const ms = (data.summary.milestone_summary as Array<{ id: number; title: string }>)
|
||||
.find((m) => m.id === task.milestone_id);
|
||||
if (ms) milestoneName.value = ms.title;
|
||||
}
|
||||
}).catch(() => {})
|
||||
);
|
||||
}
|
||||
|
||||
// Load sub-tasks via the notes endpoint with parent_id filter
|
||||
promises.push(
|
||||
apiGet<{ notes: Note[]; total: number }>(
|
||||
`/api/notes?parent_id=${task.id}&type=task&sort=created_at&order=asc&limit=50`
|
||||
).then((data) => {
|
||||
subTasks.value = data.notes;
|
||||
}).catch(() => {})
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
async function loadTask(id: number) {
|
||||
backlinks.value = [];
|
||||
await store.fetchTask(id);
|
||||
if (!store.currentTask) return;
|
||||
|
||||
const [bl] = await Promise.allSettled([
|
||||
notesStore.fetchBacklinks(id),
|
||||
loadContext(store.currentTask),
|
||||
]);
|
||||
if (bl.status === "fulfilled") backlinks.value = bl.value;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== "Escape") return;
|
||||
e.stopPropagation(); // prevent App.vue's global handler from also firing
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (active && active !== document.body) {
|
||||
(active as HTMLElement).blur();
|
||||
return;
|
||||
}
|
||||
if (store.currentTask?.project_id) {
|
||||
router.push(`/projects/${store.currentTask.project_id}`);
|
||||
} else {
|
||||
router.push("/tasks");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTask(taskId.value);
|
||||
// Capture phase so this fires before App.vue's document-level handler
|
||||
window.addEventListener("keydown", handleKeydown, true);
|
||||
});
|
||||
onUnmounted(() => window.removeEventListener("keydown", handleKeydown, true));
|
||||
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId) loadTask(Number(newId));
|
||||
});
|
||||
|
||||
const renderedBody = computed(() => {
|
||||
if (!store.currentTask) return "";
|
||||
return renderMarkdown(store.currentTask.body);
|
||||
});
|
||||
|
||||
function cycleStatus() {
|
||||
if (!store.currentTask) return;
|
||||
store.patchStatus(
|
||||
store.currentTask.id,
|
||||
statusCycle[store.currentTask.status as TaskStatus]
|
||||
);
|
||||
}
|
||||
|
||||
const forwardStatus: Record<TaskStatus, TaskStatus | null> = {
|
||||
todo: "in_progress",
|
||||
in_progress: "done",
|
||||
done: null,
|
||||
cancelled: null,
|
||||
};
|
||||
|
||||
function recurrenceSummary(rule: Record<string, unknown> | null): string | null {
|
||||
if (!rule) return null;
|
||||
if (rule.type === "interval") {
|
||||
return `Every ${rule.every} ${rule.unit}(s)`;
|
||||
}
|
||||
if (rule.type === "calendar") {
|
||||
if (rule.unit === "month") return `Monthly on day ${rule.day_of_month}`;
|
||||
if (rule.unit === "year") {
|
||||
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
const m = months[((rule.month as number) ?? 1) - 1];
|
||||
return `Yearly on ${m} ${rule.day_of_month}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const advanceLabel = computed(() => {
|
||||
const s = store.currentTask?.status as TaskStatus | undefined;
|
||||
if (!s) return null;
|
||||
const next = forwardStatus[s];
|
||||
if (!next) return null;
|
||||
return next === "in_progress" ? "→ In Progress" : "→ Done";
|
||||
});
|
||||
|
||||
function advanceStatus() {
|
||||
if (!store.currentTask) return;
|
||||
const next = forwardStatus[store.currentTask.status as TaskStatus];
|
||||
if (next) store.patchStatus(store.currentTask.id, next);
|
||||
}
|
||||
|
||||
function isOverdue(): boolean {
|
||||
if (!store.currentTask?.due_date || store.currentTask.status === "done")
|
||||
return false;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return store.currentTask.due_date < today;
|
||||
}
|
||||
|
||||
async function convertToNote() {
|
||||
if (converting.value) return;
|
||||
converting.value = true;
|
||||
try {
|
||||
await notesStore.convertToNote(taskId.value);
|
||||
const { useToastStore } = await import("@/stores/toast");
|
||||
useToastStore().show("Converted to note");
|
||||
router.push(`/notes/${taskId.value}`);
|
||||
} catch {
|
||||
const { useToastStore } = await import("@/stores/toast");
|
||||
useToastStore().show("Failed to convert task", "error");
|
||||
} finally {
|
||||
converting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onBodyClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
const tagLink = target.closest(".inline-tag") as HTMLAnchorElement | null;
|
||||
if (tagLink) {
|
||||
e.preventDefault();
|
||||
const tag = tagLink.dataset.tag;
|
||||
if (tag) {
|
||||
router.push({ path: "/notes", query: { tag } });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const wikilink = target.closest(".wikilink") as HTMLAnchorElement | null;
|
||||
if (wikilink) {
|
||||
e.preventDefault();
|
||||
const title = wikilink.dataset.title;
|
||||
if (title) {
|
||||
try {
|
||||
const note = await apiPost<Note>(
|
||||
"/api/notes/resolve-title",
|
||||
{ title }
|
||||
);
|
||||
router.push(`/notes/${note.id}`);
|
||||
} catch {
|
||||
const { useToastStore } = await import("@/stores/toast");
|
||||
useToastStore().show(`Failed to resolve note "${title}"`, "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onTagClick(tag: string) {
|
||||
router.push({ path: "/tasks", query: { tag } });
|
||||
}
|
||||
|
||||
// Sub-task progress
|
||||
const subTaskProgress = computed(() => {
|
||||
if (!subTasks.value.length) return null;
|
||||
const done = subTasks.value.filter((t) => t.status === "done").length;
|
||||
const total = subTasks.value.length;
|
||||
return { done, total, pct: Math.round((done / total) * 100) };
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="viewer-layout">
|
||||
<main class="viewer">
|
||||
<div v-if="store.loading" class="viewer-skeleton" aria-label="Loading task">
|
||||
<div class="skel-toolbar">
|
||||
<div class="skel-btn"></div>
|
||||
<div class="skel-btn skel-btn--wide"></div>
|
||||
<div class="skel-btn"></div>
|
||||
</div>
|
||||
<div class="skel-title"></div>
|
||||
<div class="skel-meta"></div>
|
||||
<div class="skel-badges"></div>
|
||||
<div class="skel-line"></div>
|
||||
<div class="skel-line skel-line--short"></div>
|
||||
<div class="skel-line"></div>
|
||||
<div class="skel-line skel-line--medium"></div>
|
||||
<div class="skel-line skel-line--short"></div>
|
||||
</div>
|
||||
<template v-else-if="store.currentTask">
|
||||
<div class="toolbar">
|
||||
<router-link
|
||||
:to="store.currentTask.project_id ? `/projects/${store.currentTask.project_id}` : '/tasks'"
|
||||
class="btn-ghost"
|
||||
>{{ store.currentTask.project_id ? "← Project" : "← Tasks" }}</router-link>
|
||||
<router-link
|
||||
:to="`/tasks/${store.currentTask.id}/edit`"
|
||||
class="btn-primary"
|
||||
>
|
||||
Edit
|
||||
</router-link>
|
||||
<button
|
||||
v-if="advanceLabel"
|
||||
class="btn-primary"
|
||||
@click="advanceStatus"
|
||||
>
|
||||
{{ advanceLabel }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary btn-compact"
|
||||
@click="convertToNote"
|
||||
:disabled="converting"
|
||||
>
|
||||
{{ converting ? "Converting..." : "Convert to Note" }}
|
||||
</button>
|
||||
<button class="btn-secondary btn-compact" @click="showShare = true">Share</button>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb: parent task → project → milestone -->
|
||||
<div
|
||||
v-if="store.currentTask.parent_id || store.currentTask.project_id"
|
||||
class="context-bar"
|
||||
>
|
||||
<router-link
|
||||
v-if="store.currentTask.parent_id"
|
||||
:to="`/tasks/${store.currentTask.parent_id}`"
|
||||
class="ctx-crumb ctx-crumb-parent"
|
||||
>
|
||||
↑ {{ store.currentTask.parent_title || "Parent task" }}
|
||||
</router-link>
|
||||
<router-link
|
||||
v-if="store.currentTask.project_id && projectTitle"
|
||||
:to="`/projects/${store.currentTask.project_id}`"
|
||||
class="ctx-crumb ctx-crumb-project"
|
||||
>
|
||||
{{ projectTitle }}
|
||||
</router-link>
|
||||
<span v-if="milestoneName" class="ctx-crumb ctx-crumb-milestone">
|
||||
{{ milestoneName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="task-title">{{ store.currentTask.title || "Untitled" }}</h1>
|
||||
<p class="meta">
|
||||
<span class="meta-item">
|
||||
<Clock :size="16" />
|
||||
Updated {{ relativeTime(store.currentTask.updated_at) }}
|
||||
</span>
|
||||
<span class="meta-sep" aria-hidden="true">·</span>
|
||||
<span class="meta-item">
|
||||
<Pencil :size="16" />
|
||||
Created {{ relativeTime(store.currentTask.created_at) }}
|
||||
</span>
|
||||
</p>
|
||||
<div class="badges">
|
||||
<StatusBadge
|
||||
:status="store.currentTask.status!"
|
||||
clickable
|
||||
@click="cycleStatus"
|
||||
/>
|
||||
<PriorityBadge :priority="store.currentTask.priority!" />
|
||||
<span
|
||||
v-if="store.currentTask.due_date"
|
||||
:class="['due-date', { overdue: isOverdue() }]"
|
||||
>
|
||||
Due: {{ store.currentTask.due_date }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="task-meta-row" v-if="store.currentTask.started_at || store.currentTask.completed_at || store.currentTask.recurrence_rule">
|
||||
<span v-if="store.currentTask.started_at" class="task-meta-item">
|
||||
Started: {{ new Date(store.currentTask.started_at).toLocaleString() }}
|
||||
</span>
|
||||
<span v-if="store.currentTask.completed_at" class="task-meta-item">
|
||||
Completed: {{ new Date(store.currentTask.completed_at).toLocaleString() }}
|
||||
</span>
|
||||
<span v-if="recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null)" class="task-meta-item task-meta-recurrence">
|
||||
↻ {{ recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tags" v-if="store.currentTask.tags.length">
|
||||
<TagPill
|
||||
v-for="tag in store.currentTask.tags"
|
||||
:key="tag"
|
||||
:tag="tag"
|
||||
@click="onTagClick"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="store.currentTask.description"
|
||||
class="task-goal-display"
|
||||
>
|
||||
<h3 class="goal-label">Goal</h3>
|
||||
<p class="goal-text">{{ store.currentTask.description }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div
|
||||
class="body prose"
|
||||
v-html="renderedBody"
|
||||
@click="onBodyClick"
|
||||
></div>
|
||||
|
||||
<!-- Sub-tasks -->
|
||||
<div v-if="subTasks.length" class="subtasks">
|
||||
<div class="subtasks-header">
|
||||
<h2 class="subtasks-title">Sub-tasks</h2>
|
||||
<span v-if="subTaskProgress" class="subtasks-progress">
|
||||
{{ subTaskProgress.done }}/{{ subTaskProgress.total }}
|
||||
<span class="subtasks-pct">({{ subTaskProgress.pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="subTaskProgress" class="subtasks-track">
|
||||
<div class="subtasks-fill" :style="{ width: subTaskProgress.pct + '%' }"></div>
|
||||
</div>
|
||||
<ul class="subtasks-list">
|
||||
<li
|
||||
v-for="sub in subTasks"
|
||||
:key="sub.id"
|
||||
class="subtask-row"
|
||||
>
|
||||
<button
|
||||
:class="['sub-dot', statusDotClass[sub.status as TaskStatus] ?? 'dot-todo']"
|
||||
:title="`${sub.status} — click to advance`"
|
||||
@click="cycleSubTaskStatus(sub)"
|
||||
></button>
|
||||
<router-link :to="`/tasks/${sub.id}/edit`" class="sub-title" :class="{ 'sub-done': sub.status === 'done' }">
|
||||
{{ sub.title || "Untitled" }}
|
||||
</router-link>
|
||||
<span v-if="sub.due_date" class="sub-due">{{ sub.due_date }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="backlinks.length" class="backlinks">
|
||||
<h3 class="backlinks-heading">
|
||||
<LinkIcon :size="16" />
|
||||
Backlinks
|
||||
<span class="backlinks-count">{{ backlinks.length }}</span>
|
||||
</h3>
|
||||
<div class="backlinks-grid">
|
||||
<router-link
|
||||
v-for="link in backlinks"
|
||||
:key="`${link.type}-${link.id}`"
|
||||
:to="`/${link.type === 'note' ? 'notes' : 'tasks'}/${link.id}`"
|
||||
class="backlink-card"
|
||||
>
|
||||
<span :class="['backlink-type-badge', `badge-${link.type}`]">{{ link.type }}</span>
|
||||
<span class="backlink-title">{{ link.title || "Untitled" }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else>Task not found.</p>
|
||||
</main>
|
||||
<TableOfContents
|
||||
v-if="store.currentTask?.body"
|
||||
:body="store.currentTask.body"
|
||||
class="toc-sidebar"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ShareDialog
|
||||
v-if="showShare && store.currentTask"
|
||||
resource-type="note"
|
||||
:resource-id="store.currentTask.id"
|
||||
:resource-title="store.currentTask.title || '(untitled)'"
|
||||
@close="showShare = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/viewer-shared.css" />
|
||||
<style scoped>
|
||||
.viewer-layout {
|
||||
display: flex;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
gap: 2rem;
|
||||
}
|
||||
.viewer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 1100px;
|
||||
margin: 2rem 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.toc-sidebar {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.toc-sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.83rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.meta-sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.due-date {
|
||||
font-size: 0.85rem;
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.due-date.overdue {
|
||||
color: var(--fs-overdue);
|
||||
font-weight: 500;
|
||||
}
|
||||
.task-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.task-meta-item {
|
||||
font-size: 0.78rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.task-meta-recurrence {
|
||||
color: var(--fs-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Sub-tasks */
|
||||
.subtasks {
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.subtasks-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.subtasks-title {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.subtasks-progress {
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.subtasks-pct {
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.subtasks-track {
|
||||
height: 4px;
|
||||
background: var(--fs-surface-raised);
|
||||
border-radius: 2px;
|
||||
margin-bottom: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.subtasks-fill {
|
||||
height: 100%;
|
||||
background: var(--fs-status-done);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.subtasks-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.subtask-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
.subtask-row:hover {
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
.sub-dot {
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: transform 0.1s, opacity 0.1s;
|
||||
}
|
||||
.sub-dot:hover {
|
||||
transform: scale(1.25);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.dot-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-text-tertiary);
|
||||
}
|
||||
.sub-title {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
color: var(--fs-text-primary);
|
||||
text-decoration: none;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sub-title:hover {
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.sub-title.sub-done {
|
||||
color: var(--fs-text-tertiary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.sub-due {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.backlinks {
|
||||
margin-top: 2.5rem;
|
||||
border-top: 1px solid var(--fs-border-color);
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
.backlinks-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.backlinks-count {
|
||||
margin-left: 0.2rem;
|
||||
font-size: 0.72rem;
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: 999px;
|
||||
padding: 0 0.4rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.backlinks-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.backlink-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--fs-radius-lg);
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
text-decoration: none;
|
||||
color: var(--fs-text-primary);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.backlink-card:hover {
|
||||
border-color: color-mix(in srgb, var(--fs-accent) 50%, transparent);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
color: var(--fs-accent);
|
||||
}
|
||||
.backlink-type-badge {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-weight: 500;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge-note {
|
||||
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||
color: var(--fs-accent);
|
||||
border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
|
||||
}
|
||||
.badge-task {
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
color: #d97706;
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 30%, transparent);
|
||||
}
|
||||
.backlink-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Skeleton loader ── */
|
||||
@keyframes skel-shine {
|
||||
to { background-position: 200% center; }
|
||||
}
|
||||
.viewer-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.skel-btn,
|
||||
.skel-title,
|
||||
.skel-meta,
|
||||
.skel-badges,
|
||||
.skel-line {
|
||||
border-radius: var(--fs-radius-sm);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--fs-surface-raised) 25%,
|
||||
color-mix(in srgb, var(--fs-text-tertiary) 18%, var(--fs-surface-raised)) 50%,
|
||||
var(--fs-surface-raised) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skel-shine 1.5s ease infinite;
|
||||
}
|
||||
.skel-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.skel-btn { width: 70px; height: 32px; }
|
||||
.skel-btn--wide { width: 90px; }
|
||||
.skel-title { height: 2.2rem; width: 65%; border-radius: var(--fs-radius-lg); }
|
||||
.skel-meta { height: 0.85rem; width: 45%; }
|
||||
.skel-badges { height: 1.6rem; width: 30%; border-radius: 999px; }
|
||||
.skel-line { height: 0.9rem; }
|
||||
.skel-line--short { width: 50%; }
|
||||
.skel-line--medium { width: 78%; }
|
||||
|
||||
/* ── Goal block + auto-summary banner ─────────────────────────────────────── */
|
||||
.task-goal-display {
|
||||
border-left: 2px solid var(--fs-border-color);
|
||||
padding: 0.4rem 0 0.4rem 0.9rem;
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.goal-label {
|
||||
font-family: var(--fs-font-display);
|
||||
font-style: italic;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fs-text-tertiary);
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.goal-text {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
color: var(--fs-text-primary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,441 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { apiGet, apiPost, apiPut, apiDelete, apiErrorMessage } from "@/api/client";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type { User } from "@/types/auth";
|
||||
import { fmtDate } from "@/utils/dateFormat";
|
||||
|
||||
interface Invitation {
|
||||
id: number;
|
||||
email: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const toastStore = useToastStore();
|
||||
|
||||
const users = ref<User[]>([]);
|
||||
const registrationOpen = ref(false);
|
||||
const loading = ref(true);
|
||||
const toggling = ref(false);
|
||||
const confirmDeleteId = ref<number | null>(null);
|
||||
const deleting = ref<number | null>(null);
|
||||
|
||||
const inviteEmail = ref("");
|
||||
const sendingInvite = ref(false);
|
||||
const invitations = ref<Invitation[]>([]);
|
||||
const revokingId = ref<number | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchUsers(), fetchRegistration(), fetchInvitations()]);
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
async function fetchUsers() {
|
||||
try {
|
||||
const data = await apiGet<{ users: User[] }>("/api/admin/users");
|
||||
users.value = data.users;
|
||||
} catch {
|
||||
toastStore.show("Failed to load users", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRegistration() {
|
||||
try {
|
||||
const data = await apiGet<{ open: boolean }>("/api/admin/registration");
|
||||
registrationOpen.value = data.open;
|
||||
} catch {
|
||||
// Ignore — will default to false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchInvitations() {
|
||||
try {
|
||||
const data = await apiGet<{ invitations: Invitation[] }>("/api/admin/invitations");
|
||||
invitations.value = data.invitations;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function sendInvite() {
|
||||
const email = inviteEmail.value.trim().toLowerCase();
|
||||
if (!email) return;
|
||||
sendingInvite.value = true;
|
||||
try {
|
||||
await apiPost("/api/admin/invitations", { email });
|
||||
toastStore.show(`Invitation sent to ${email}`);
|
||||
inviteEmail.value = "";
|
||||
await fetchInvitations();
|
||||
} catch (e: unknown) {
|
||||
toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
|
||||
} finally {
|
||||
sendingInvite.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeInvitation(id: number) {
|
||||
revokingId.value = id;
|
||||
try {
|
||||
await apiDelete(`/api/admin/invitations/${id}`);
|
||||
invitations.value = invitations.value.filter((inv) => inv.id !== id);
|
||||
toastStore.show("Invitation revoked");
|
||||
} catch {
|
||||
toastStore.show("Failed to revoke invitation", "error");
|
||||
} finally {
|
||||
revokingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRegistration() {
|
||||
toggling.value = true;
|
||||
try {
|
||||
const data = await apiPut<{ open: boolean }>("/api/admin/registration", {
|
||||
open: !registrationOpen.value,
|
||||
});
|
||||
registrationOpen.value = data.open;
|
||||
toastStore.show(data.open ? "Registration opened" : "Registration closed");
|
||||
} catch {
|
||||
toastStore.show("Failed to update registration setting", "error");
|
||||
} finally {
|
||||
toggling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(userId: number) {
|
||||
if (confirmDeleteId.value === userId) {
|
||||
deleteUser(userId);
|
||||
} else {
|
||||
confirmDeleteId.value = userId;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
confirmDeleteId.value = null;
|
||||
}
|
||||
|
||||
async function deleteUser(userId: number) {
|
||||
confirmDeleteId.value = null;
|
||||
deleting.value = userId;
|
||||
try {
|
||||
await apiDelete(`/api/admin/users/${userId}`);
|
||||
users.value = users.value.filter((u) => u.id !== userId);
|
||||
toastStore.show("User deleted");
|
||||
} catch (e: unknown) {
|
||||
toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
|
||||
} finally {
|
||||
deleting.value = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="users-page">
|
||||
<h1>User Management</h1>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Registration</h2>
|
||||
<div class="registration-row">
|
||||
<div class="registration-info">
|
||||
<p class="registration-status">
|
||||
Registration is currently
|
||||
<strong :class="registrationOpen ? 'text-success' : 'text-muted'">
|
||||
{{ registrationOpen ? "open" : "closed" }}
|
||||
</strong>
|
||||
</p>
|
||||
<p class="field-hint">
|
||||
When closed, new users can only be added by an administrator.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-primary btn-toggle"
|
||||
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
|
||||
@click="toggleRegistration"
|
||||
:disabled="toggling"
|
||||
>
|
||||
{{ toggling ? "Updating..." : registrationOpen ? "Close Registration" : "Open Registration" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Invite User</h2>
|
||||
<form class="invite-form" @submit.prevent="sendInvite">
|
||||
<input
|
||||
v-model="inviteEmail"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
class="input invite-input"
|
||||
required
|
||||
:disabled="sendingInvite"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
:disabled="sendingInvite || !inviteEmail.trim()"
|
||||
>
|
||||
{{ sendingInvite ? "Sending..." : "Send Invite" }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="field-hint">Send an invitation link to allow someone to register, even when public registration is closed.</p>
|
||||
|
||||
<div v-if="invitations.length > 0" class="invite-list">
|
||||
<h3>Pending Invitations</h3>
|
||||
<table class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th class="hide-mobile">Sent</th>
|
||||
<th class="hide-mobile">Expires</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="inv in invitations" :key="inv.id">
|
||||
<td class="cell-email">{{ inv.email }}</td>
|
||||
<td class="hide-mobile cell-date">{{ fmtDate(inv.created_at) }}</td>
|
||||
<td class="hide-mobile cell-date">{{ fmtDate(inv.expires_at) }}</td>
|
||||
<td class="cell-actions">
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
@click="revokeInvitation(inv.id)"
|
||||
:disabled="revokingId !== null"
|
||||
>
|
||||
{{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Users</h2>
|
||||
|
||||
<div v-if="loading" class="loading-msg">Loading users...</div>
|
||||
|
||||
<div v-else-if="users.length === 0" class="empty-msg">No users found.</div>
|
||||
|
||||
<table v-else class="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th class="hide-mobile">Email</th>
|
||||
<th>Role</th>
|
||||
<th class="hide-mobile">Joined</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.id">
|
||||
<td class="cell-username">{{ u.username }}</td>
|
||||
<td class="hide-mobile cell-email">{{ u.email || "—" }}</td>
|
||||
<td>
|
||||
<span class="role-badge" :class="u.role === 'admin' ? 'role-admin' : 'role-user'">
|
||||
{{ u.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="hide-mobile cell-date">{{ fmtDate(u.created_at) }}</td>
|
||||
<td class="cell-actions">
|
||||
<template v-if="u.id === authStore.user?.id">
|
||||
<span class="you-label">You</span>
|
||||
</template>
|
||||
<template v-else-if="confirmDeleteId === u.id">
|
||||
<button
|
||||
class="btn-danger btn-compact"
|
||||
@click="confirmDelete(u.id)"
|
||||
:disabled="deleting !== null"
|
||||
>
|
||||
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
|
||||
</button>
|
||||
<button class="btn-ghost btn-compact" @click="cancelDelete">Cancel</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
@click="confirmDelete(u.id)"
|
||||
:disabled="deleting !== null"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.users-page {
|
||||
max-width: 1200px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
.users-page h1 {
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
.settings-section {
|
||||
background: var(--fs-surface-raised);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-lg);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.settings-section h2 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Invite form */
|
||||
.invite-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.invite-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
font-size: 0.95rem;
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.invite-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--fs-accent);
|
||||
}
|
||||
.invite-list {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.invite-list h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
|
||||
/* Registration toggle */
|
||||
.registration-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.registration-info {
|
||||
flex: 1;
|
||||
}
|
||||
.registration-status {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.text-success {
|
||||
color: var(--fs-success);
|
||||
}
|
||||
.text-muted {
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
.field-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
/* The one genuine override: 'close registration' must NOT read as the
|
||||
primary action it sits on. Scoped, so it beats the shared variant. */
|
||||
.btn-toggle-close {
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-primary);
|
||||
border: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.btn-toggle-close:hover:not(:disabled) {
|
||||
border-color: var(--fs-warning);
|
||||
color: var(--fs-warning);
|
||||
}
|
||||
|
||||
/* Users table */
|
||||
.loading-msg,
|
||||
.empty-msg {
|
||||
text-align: center;
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.users-table th {
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fs-text-tertiary);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
}
|
||||
.users-table td {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-bottom: 1px solid var(--fs-border-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.users-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.cell-username {
|
||||
font-weight: 600;
|
||||
}
|
||||
.cell-email {
|
||||
color: var(--fs-text-secondary);
|
||||
}
|
||||
.cell-date {
|
||||
color: var(--fs-text-tertiary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cell-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Role badges */
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
.role-admin {
|
||||
color: var(--fs-accent);
|
||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||
}
|
||||
.role-user {
|
||||
color: var(--fs-text-tertiary);
|
||||
background: var(--fs-surface-raised);
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.you-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.registration-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.btn-toggle {
|
||||
width: 100%;
|
||||
}
|
||||
.invite-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,17 @@
|
||||
{
|
||||
"name": "scribe",
|
||||
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||
"version": "0.1.38",
|
||||
"author": { "name": "Bryan Van Deusen" },
|
||||
"version": "2026.09.04.0140",
|
||||
"author": {
|
||||
"name": "Bryan Van Deusen"
|
||||
},
|
||||
"mcpServers": {
|
||||
"scribe": {
|
||||
"type": "http",
|
||||
"url": "${user_config.api_endpoint}/mcp",
|
||||
"headers": { "Authorization": "Bearer ${user_config.api_token}" }
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${user_config.api_token}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"userConfig": {
|
||||
@@ -19,7 +23,7 @@
|
||||
"api_token": {
|
||||
"type": "string",
|
||||
"title": "Scribe API key",
|
||||
"description": "An fmcp_ API key from Settings → API Keys (read scope is enough for the session-start hook; write scope to use the tools)",
|
||||
"description": "An fmcp_ API key from Settings \u2192 API Keys (read scope is enough for the session-start hook; write scope to use the tools)",
|
||||
"sensitive": true
|
||||
}
|
||||
}
|
||||
|
||||
+25
-4
@@ -52,8 +52,24 @@ On install you'll be asked for:
|
||||
but never stop it; silent when nothing is recorded, which is most of the time.
|
||||
Two framings: a REUSE menu (similar/nearby records), and a SYNC nudge when a
|
||||
snippet records the exact file being edited — "updating the record is part of
|
||||
the edit" — each with its own once-per-session dedup.
|
||||
Toggle in **Settings → Knowledge auto-inject**.
|
||||
the edit" — each with its own once-per-session dedup. A third, ledger-fed
|
||||
line names a duplicate family (no canon) or a canon recorded elsewhere for
|
||||
the names being written (its own dedup channel, `exclude_derive`).
|
||||
Fail-open but not fail-silent: a configured instance that does not answer
|
||||
in time is said, once per outage ("Scribe did not answer … this write went
|
||||
UNCHECKED"), so a session can tell "checked, nothing there" from "never
|
||||
checked"; an answer clears the marker. The local by-name arm needs no
|
||||
server and always runs. Toggle in **Settings → Knowledge auto-inject**.
|
||||
- `hooks/hooks.json` → PostToolUse hook on `Bash`
|
||||
(`hooks/scribe_after_write.sh`): code written through sed/heredocs/scripts
|
||||
never reaches the PreToolUse hook, so this one diffs the working tree after
|
||||
every Bash call (per-session path+blob snapshot; one `git status` when
|
||||
nothing changed) and runs the same arms on the definitions just written,
|
||||
through the same endpoint and the same dedup channels. `additionalContext`
|
||||
only; never blocks, and shares the pre-write hook's once-per-outage "did not
|
||||
answer" line (8 s budget here — it runs after the tool, so it gates
|
||||
nothing). The extractor, the prose/data skip list, the local by-name
|
||||
duplicate arm and the outage line are shared in `hooks/scribe_defs.sh`.
|
||||
- `skills/` → the universal process-skills, surfaced by description match.
|
||||
- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync`
|
||||
command → generate `~/.claude/skills/scribe-proc-*` stubs from your Scribe
|
||||
@@ -62,8 +78,13 @@ On install you'll be asked for:
|
||||
|
||||
## Notes
|
||||
|
||||
- Set a `version` bump in `.claude-plugin/plugin.json` per release so clients
|
||||
pick up changes.
|
||||
- **Do not hand-edit `version` in `.claude-plugin/plugin.json`.** It is minted
|
||||
from the clock — run `python3 scripts/mint_plugin_version.py` (or `make
|
||||
mint-plugin`, where `make` is installed) after changing anything under
|
||||
`plugin/`, and commit the result. The installer decides whether to refresh the cache it
|
||||
executes from by comparing that string, so content that ships without a new
|
||||
version reaches the repo and stops there (#2209). CI fails the lane if you
|
||||
forget.
|
||||
- The session-start, auto-inject and prior-art hooks need only a **read**-scoped
|
||||
key; the MCP tools need **write** scope to create/update. Every hook is a GET
|
||||
for that reason — a read key cannot POST.
|
||||
|
||||
@@ -33,6 +33,26 @@
|
||||
"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": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_after_write.sh\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env bash
|
||||
# Scribe plugin — PostToolUse write-path trigger on Bash (#2901).
|
||||
#
|
||||
# scribe_prior_art.sh fires before a Write/Edit TOOL CALL. Code written any
|
||||
# other way — sed, heredocs, python edit scripts, `cat > file` — never reached
|
||||
# it, so a whole class of edits (the ones a long session makes most) got no
|
||||
# prior-art hint, no ledger feed and no duplicate-family warning. This hook
|
||||
# closes that: after EVERY Bash call it asks git what changed in the working
|
||||
# tree since it last looked, and runs the same arms on the definitions that
|
||||
# were just written — the local by-name duplicate arm, the recorded prior-art
|
||||
# arms and the ledger's derive/divergence checks (#2900/#2793), via the same
|
||||
# /api/plugin/prior-art endpoint the pre-write hook uses.
|
||||
#
|
||||
# Post-hoc by a few seconds, in the same moment and the same session: "the
|
||||
# copy just landed; here is its family" — not "an audit found it later".
|
||||
#
|
||||
# Cheap when nothing changed: one `git status`. State per session, beside the
|
||||
# pre-write hook's (its three dedup channels are SHARED, so a family named by
|
||||
# one hook is not named again by the other):
|
||||
# ${TMPDIR:-/tmp}/scribe-afterwrite/<sid>.snap path<TAB>blob-hash of every
|
||||
# dirty/untracked file last seen
|
||||
# ${TMPDIR:-/tmp}/scribe-priorart/<sid>.* the dedup channels
|
||||
#
|
||||
# NEVER BLOCKS. It returns `additionalContext` only (no decision — there is
|
||||
# nothing left to decide, the write already happened). Any failure —
|
||||
# unconfigured, unreachable, not a git repo, malformed — exits 0 in silence.
|
||||
#
|
||||
# Config (same as the other hooks):
|
||||
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
||||
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
||||
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
||||
set -uo pipefail
|
||||
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
command -v git >/dev/null 2>&1 || exit 0
|
||||
|
||||
# shellcheck source=plugin/hooks/scribe_defs.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
||||
|
||||
# PostToolUse delivers { session_id, cwd, tool_name, tool_input, tool_response }.
|
||||
event=$(cat 2>/dev/null || true)
|
||||
tool_name=$(printf '%s' "$event" | jq -r '.tool_name // empty' 2>/dev/null) || exit 0
|
||||
[ "$tool_name" = "Bash" ] || 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=""
|
||||
work_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
||||
repo_root=$(git -C "$work_dir" rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
[ -n "$repo_root" ] || exit 0
|
||||
|
||||
safe_sid=$(printf '%s' "${session_id:-nosession}" | tr -c 'A-Za-z0-9._-' '_')
|
||||
snap_dir="${TMPDIR:-/tmp}/scribe-afterwrite"
|
||||
mkdir -p "$snap_dir" 2>/dev/null || true
|
||||
snap="$snap_dir/${safe_sid}.snap"
|
||||
|
||||
# What is dirty now: every modified / added / untracked path, with the blob
|
||||
# hash of its working-tree content. Hash, not mtime: portable (no stat
|
||||
# flags), exact (a touch is not a change), and untracked files hash the same
|
||||
# way tracked ones do.
|
||||
current=""
|
||||
while IFS= read -r line; do
|
||||
[ -n "$line" ] || continue
|
||||
status=${line:0:2}
|
||||
path=${line:3}
|
||||
case "$status" in
|
||||
D*|*D) continue ;; # a deletion defines nothing
|
||||
esac
|
||||
case "$path" in
|
||||
*" -> "*) path=${path##* -> } ;; # rename: the new name
|
||||
esac
|
||||
# Porcelain quotes paths with special characters; those are skipped rather
|
||||
# than unquoted badly — a filename needing quotes is not where shapes live.
|
||||
case "$path" in
|
||||
\"*) continue ;;
|
||||
esac
|
||||
[ -f "$repo_root/$path" ] || continue
|
||||
sha=$(git -C "$repo_root" hash-object -- "$path" 2>/dev/null) || continue
|
||||
current="${current}${path}"$'\t'"${sha}"$'\n'
|
||||
done < <(git -C "$repo_root" status --porcelain --untracked-files=all 2>/dev/null)
|
||||
|
||||
previous=""
|
||||
[ -f "$snap" ] && previous=$(cat "$snap" 2>/dev/null || true)
|
||||
first_run=0
|
||||
[ -f "$snap" ] || first_run=1
|
||||
# Write the new snapshot NOW, before anything can fail below — the next call
|
||||
# must compare against this tree, whatever happens to this one's hint.
|
||||
printf '%s' "$current" > "$snap" 2>/dev/null || true
|
||||
|
||||
# Changed = a (path, hash) pair not in the previous snapshot. On the very
|
||||
# first call of a session there is no previous snapshot; rather than report
|
||||
# every pre-existing dirty file as "just written", take only files touched in
|
||||
# the last minute — the Bash call that just ran is the likely author.
|
||||
changed=""
|
||||
while IFS=$'\t' read -r path sha; do
|
||||
[ -n "${path:-}" ] || continue
|
||||
if [ "$first_run" = 1 ]; then
|
||||
[ -n "$(find "$repo_root/$path" -mmin -1 2>/dev/null)" ] || continue
|
||||
else
|
||||
case "$previous" in
|
||||
*"${path}"$'\t'"${sha}"*) continue ;;
|
||||
esac
|
||||
fi
|
||||
scribe_skip_path "$path" && continue
|
||||
changed="${changed}${path}"$'\n'
|
||||
done <<< "$current"
|
||||
[ -n "$changed" ] || exit 0
|
||||
|
||||
scribe_config || : # sets url/token; the call below is guarded on them
|
||||
repo=$(git -C "$repo_root" remote get-url origin 2>/dev/null || true)
|
||||
repo_q=""
|
||||
if [ -n "$repo" ]; then
|
||||
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
||||
fi
|
||||
|
||||
# The dedup channels are the PRE-write hook's files, on purpose (see header).
|
||||
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
||||
mkdir -p "$state_dir" 2>/dev/null || true
|
||||
idfile="$state_dir/${safe_sid}.ids"
|
||||
syncfile="$state_dir/${safe_sid}.sync.ids"
|
||||
derivefile="$state_dir/${safe_sid}.derive.ids"
|
||||
|
||||
combined=""
|
||||
n_files=0
|
||||
while IFS= read -r rel_path; do
|
||||
[ -n "${rel_path:-}" ] || continue
|
||||
# A Bash call that rewrote many files is a refactor or a generator, not a
|
||||
# shape being instantiated; four is enough to name what matters.
|
||||
n_files=$((n_files + 1))
|
||||
[ "$n_files" -le 4 ] || break
|
||||
file_path="$repo_root/$rel_path"
|
||||
|
||||
# The code just written: the ADDED lines of the uncommitted diff for a
|
||||
# tracked file (sed, not cut: this strips one marker char per line, it is
|
||||
# not a payload cap), the whole file when untracked.
|
||||
if git -C "$repo_root" ls-files --error-unmatch -- "$rel_path" >/dev/null 2>&1; then
|
||||
code=$(git -C "$repo_root" diff -U0 -- "$rel_path" 2>/dev/null | grep '^+' | grep -v '^+++' | sed 's/^+//') || code=""
|
||||
else
|
||||
code=$(cat "$file_path" 2>/dev/null) || code=""
|
||||
fi
|
||||
[ -n "$code" ] || continue
|
||||
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12) || names=""
|
||||
# Nothing DEFINED in what was written (prose, data, a call-site edit) →
|
||||
# nothing to say; the arms are about shapes.
|
||||
[ -n "$names" ] || continue
|
||||
|
||||
local_lines=$(scribe_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
|
||||
local_context=""
|
||||
if [ -n "$local_lines" ]; then
|
||||
local_context="> Already defined elsewhere in this repo — \`${rel_path}\` (just written) adds another copy; check before keeping it (\`git grep\` shown; a nudge, not a gate):"$'\n'"${local_lines}"
|
||||
fi
|
||||
|
||||
context=""
|
||||
body=""
|
||||
reached="" # "" unconfigured (no call owed) · 1 answered · 0 did not
|
||||
unreached_context=""
|
||||
if [ -n "$url" ] && [ -n "$token" ]; then
|
||||
q=$(printf '%s' "$code" | head -c 1200)
|
||||
path_enc=$(printf '%s' "$rel_path" | jq -sRr '@uri' 2>/dev/null) || path_enc=""
|
||||
code_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || code_enc=""
|
||||
shapes_q=""
|
||||
enc=$(printf '%s\n' "$names" \
|
||||
| awk -F'\t' 'NF>=2 {printf "%s%s:%s", (n++?",":""), $1, $2}' \
|
||||
| jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
[ -n "$enc" ] && shapes_q="&shapes=${enc}"
|
||||
exclude_q=""; sync_exclude_q=""; derive_exclude_q=""
|
||||
if [ -f "$idfile" ]; then
|
||||
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
||||
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
||||
fi
|
||||
if [ -f "$syncfile" ]; then
|
||||
sync_seen=$(tr '\n' ',' < "$syncfile" 2>/dev/null | sed 's/,$//')
|
||||
[ -n "$sync_seen" ] && sync_exclude_q="&exclude_sync_ids=${sync_seen}"
|
||||
fi
|
||||
if [ -f "$derivefile" ]; then
|
||||
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | jq -sRr '@uri' 2>/dev/null) || derive_seen=""
|
||||
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
|
||||
fi
|
||||
# The rules marker the SessionStart hook stored, handed back so the server
|
||||
# can say whether those rules moved since (milestone 323). Nothing stored
|
||||
# means nothing sent, which the server reads as silence rather than as a
|
||||
# mismatch — an install that never reached /api/plugin/context must not
|
||||
# start claiming its rules changed.
|
||||
etag_q=""
|
||||
if [ -f "$state_dir/${safe_sid}.rules_etag" ]; then
|
||||
held=$(jq -sRr '@uri' < "$state_dir/${safe_sid}.rules_etag" 2>/dev/null) || held=""
|
||||
[ -n "$held" ] && etag_q="&rules_etag=${held}"
|
||||
fi
|
||||
if [ -n "$path_enc" ]; then
|
||||
# 8s, not the pre-write hook's 5: this hook runs AFTER the tool, so it
|
||||
# gates nothing the session is waiting on, and the first prior-art call
|
||||
# after a redeploy is a cold start (embedding warm-up, ~4.6s observed)
|
||||
# that a 4s cap turned into a silent fail-open — the one write a
|
||||
# session most wants the ledger's word on lost it.
|
||||
reached=1
|
||||
body=$(curl -fsS --max-time 8 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${shapes_q}${etag_q}" 2>/dev/null) || { body=""; reached=0; }
|
||||
# A call that was owed and didn't come back is said, once per outage
|
||||
# (#2932) — shared marker with the pre-write hook, so one outage is one
|
||||
# line however the code was written.
|
||||
if [ "$reached" = 1 ]; then
|
||||
scribe_reached "$state_dir" "$safe_sid"
|
||||
else
|
||||
unreached_context=$(scribe_unreached "$state_dir" "$safe_sid" 8 "$rel_path")
|
||||
fi
|
||||
fi
|
||||
if [ -n "$body" ]; then
|
||||
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context=""
|
||||
if [ -n "$context" ]; then
|
||||
printf '%s' "$body" | jq -r '((.note_ids // []) - (.sync_note_ids // []))[]?' 2>/dev/null >> "$idfile" || true
|
||||
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
|
||||
printf '%s' "$body" | jq -r '(.derive_keys // [])[]?' 2>/dev/null >> "$derivefile" || true
|
||||
# Several files in one call may name the same family: keep each
|
||||
# token once, so the next request's exclude list stays exact.
|
||||
for f in "$idfile" "$syncfile" "$derivefile"; do
|
||||
[ -s "$f" ] && { sort -u -o "$f" "$f" 2>/dev/null || true; }
|
||||
done
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# The record nudge (#2664), same gate as the pre-write hook: duplication
|
||||
# demonstrated locally AND nothing recorded for it — and (#2932) never on a
|
||||
# call that did not answer; "nothing recorded" is a claim only an answer
|
||||
# can back.
|
||||
if [ -n "$local_lines" ] && [ "$reached" != 0 ]; then
|
||||
n_recorded=$(printf '%s' "$body" | jq -r '.note_ids | length' 2>/dev/null) || n_recorded=0
|
||||
if [ "${n_recorded:-0}" = "0" ] || [ "$n_recorded" = "" ]; then
|
||||
local_context="${local_context}"$'\n'"> None of those existing copies is recorded in Scribe. If the version just written is the canonical one — or this edit is consolidating the copies — record it now with create_snippet so the next session is offered it instead of writing another copy."
|
||||
fi
|
||||
fi
|
||||
|
||||
part="$local_context"
|
||||
if [ -n "$context" ]; then
|
||||
[ -n "$part" ] && part="${part}"$'\n'
|
||||
part="${part}${context}"
|
||||
fi
|
||||
if [ -n "$unreached_context" ]; then
|
||||
[ -n "$part" ] && part="${part}"$'\n'
|
||||
part="${part}${unreached_context}"
|
||||
fi
|
||||
[ -n "$part" ] || continue
|
||||
[ -n "$combined" ] && combined="${combined}"$'\n'
|
||||
combined="${combined}${part}"
|
||||
done <<< "$changed"
|
||||
|
||||
[ -n "$combined" ] || exit 0
|
||||
jq -n --arg c "$combined" \
|
||||
'{hookSpecificOutput: {hookEventName: "PostToolUse", additionalContext: $c}}'
|
||||
exit 0
|
||||
@@ -23,6 +23,9 @@
|
||||
# note is injected at most once per session. Passed back as exclude_ids.
|
||||
set -uo pipefail
|
||||
|
||||
# shellcheck source=plugin/hooks/scribe_defs.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
||||
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
command -v curl >/dev/null 2>&1 || exit 0
|
||||
|
||||
@@ -35,13 +38,8 @@ event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_c
|
||||
# Nothing to retrieve against.
|
||||
[ -n "$prompt" ] || exit 0
|
||||
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
||||
case "$url" in *'${'*) url="" ;; esac
|
||||
case "$token" in *'${'*) token="" ;; esac
|
||||
# Unconfigured install → silent (auto-inject is pure enrichment).
|
||||
[ -n "$url" ] && [ -n "$token" ] || exit 0
|
||||
scribe_config || exit 0
|
||||
|
||||
# Cap the query length — a giant prompt makes a giant URL for no extra signal.
|
||||
# `head -c`, not `cut -c1-2000`: cut is line-oriented and caps EACH LINE, so a
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env bash
|
||||
# shellcheck shell=bash
|
||||
# Scribe plugin — the pieces the hooks share (#2901, #2278).
|
||||
#
|
||||
# scribe_prior_art.sh fires BEFORE a Write/Edit tool call; scribe_after_write.sh
|
||||
# fires AFTER a Bash tool call and diffs the working tree, so code written by
|
||||
# sed/heredocs/scripts gets the same prior-art and ledger checks. Both need the
|
||||
# same three things, kept here so they cannot drift apart:
|
||||
#
|
||||
# scribe_skip_path PATH formats that hold prose or data, not shapes
|
||||
# scribe_defs stdin code → "kind<TAB>name" per definition
|
||||
# scribe_local_dups ROOT REL "kind<TAB>name" lines on stdin → the by-name
|
||||
# local-duplicate lines (ARM 1, #2280)
|
||||
# scribe_unreached STATE SID SECS REL the "Scribe didn't answer" line, once
|
||||
# per outage (#2932) — or nothing, if said lately
|
||||
# scribe_reached STATE SID the server answered: the next outage speaks again
|
||||
# scribe_config sets `url` + `token` from the env, returns 0
|
||||
# only if BOTH are usable (#2278)
|
||||
#
|
||||
# Sourced, not executed: `. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"`.
|
||||
|
||||
# Skip formats that hold prose or data rather than reusable code. Purely to
|
||||
# avoid a pointless round-trip — the server would return nothing for these
|
||||
# anyway. Config formats are NOT skipped: a CI workflow or a compose file is
|
||||
# often exactly the thing worth reusing.
|
||||
scribe_skip_path() {
|
||||
case "$1" in
|
||||
*.md|*.mdx|*.txt|*.rst|*.json|*.lock|*.log|*.csv|*.tsv|*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf)
|
||||
return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# kind<TAB>name for each thing a piece of code DEFINES, in source order. One
|
||||
# program, two consumers: the local duplicate arm (every definition in the
|
||||
# payload) and the ledger feed (#2791, below: the definitions being written,
|
||||
# or the one enclosing an Edit). Rule-for-rule mirrored by the server's
|
||||
# services/coverage.py extract_shapes — ledger rows are keyed by what THAT
|
||||
# sees, so the two must agree on what counts as a definition.
|
||||
scribe_defs() {
|
||||
awk '
|
||||
{
|
||||
# CSS class definition: .name { or .name,
|
||||
if (match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) {
|
||||
t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t)
|
||||
if (t != "") print "css\t" t; next
|
||||
}
|
||||
line = $0; sub(/^[[:space:]]+/, "", line)
|
||||
# Strip leading declaration modifiers so the definition keyword is the
|
||||
# first word regardless of language (export/pub/private/suspend/...).
|
||||
sub(/^((pub(\([a-z]+\))?|export|default|private|internal|protected|public|static|suspend|async|open|sealed|data|abstract|final|inline|unsafe|extern|override)[[:space:]]+)*/, "", line)
|
||||
# Go method with receiver: func (r *T) Name(
|
||||
if (match(line, /^func[[:space:]]*\([^)]*\)[[:space:]]*[A-Za-z_]/)) {
|
||||
t = line; sub(/^func[[:space:]]*\([^)]*\)[[:space:]]*/, "", t)
|
||||
sub(/[^A-Za-z0-9_].*$/, "", t)
|
||||
if (t != "") print "sym\t" t; next
|
||||
}
|
||||
# Keyword-announced definitions, functions and named types alike.
|
||||
# Dunders are skipped: every class defines __init__, so "already defined
|
||||
# in N other files" is guaranteed noise for them — and noise is what
|
||||
# teaches sessions to skip the hint.
|
||||
if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) {
|
||||
t = line; sub(/^[a-z]+[[:space:]]+/, "", t)
|
||||
sub(/[^A-Za-z0-9_$].*$/, "", t)
|
||||
# `type` defines only when something follows the name (= or {); an
|
||||
# import specifier `type Foo,` is the same two words and defines
|
||||
# nothing (mirror of coverage.py, #2904).
|
||||
if (line ~ /^type[[:space:]]/) {
|
||||
rest = line; sub(/^type[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/, "", rest)
|
||||
if (rest !~ /[={]/) next
|
||||
}
|
||||
if (t != "" && t !~ /^__.*__$/) print "sym\t" t; next
|
||||
}
|
||||
# Arrow/expression assignment: const name = (…) / let name = async (
|
||||
if (match(line, /^(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/)) {
|
||||
t = line; sub(/^(const|let)[[:space:]]+/, "", t)
|
||||
sub(/[^A-Za-z0-9_$].*$/, "", t)
|
||||
if (t != "") print "sym\t" t; next
|
||||
}
|
||||
}
|
||||
' 2>/dev/null
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist?
|
||||
#
|
||||
# The recorded arms ask Scribe what was RECORDED; the ledger arm (#2900) asks
|
||||
# what a BOUND repo's ledger knows. A helper nobody recorded, in a repo nobody
|
||||
# bound, is invisible to both — which is how `.btn-primary` came to be defined
|
||||
# four times, already diverged. This arm asks the one question only the
|
||||
# developer's machine can answer, inside the repo, holding the code about to
|
||||
# be written: no index, no storage, no server — it runs even on an install
|
||||
# that has never configured Scribe.
|
||||
#
|
||||
# Definition-shaped patterns only. Grepping for bare occurrences would match
|
||||
# every CALL site and drown the real finding — and a hint that is mostly noise
|
||||
# is one people learn to skip, which is worse than none. ALL code, not a
|
||||
# language shortlist (#2682): the same keyword family scribe_defs announces.
|
||||
#
|
||||
# $1 repo root, $2 repo-relative path of the file being written (excluded from
|
||||
# the grep — it would always match itself on an Edit). Definitions on stdin.
|
||||
# Prints one "> - `name` is already defined in N other file(s): …" per hit.
|
||||
scribe_local_dups() {
|
||||
local root="$1" rel="$2" kind name pat hits count label files
|
||||
while IFS=$'\t' read -r kind name; do
|
||||
[ -n "${name:-}" ] || continue
|
||||
case "$kind" in
|
||||
css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;;
|
||||
*) pat="(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+${name}[^A-Za-z0-9_]|func[[:space:]]*\([^)]*\)[[:space:]]*${name}[[:space:]]*\(|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
|
||||
esac
|
||||
# -I skips binaries; :(exclude) drops the file being written.
|
||||
hits=$(git -C "$root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel}" 2>/dev/null | head -4) || hits=""
|
||||
[ -n "$hits" ] || continue
|
||||
count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0)
|
||||
label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name")
|
||||
files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//')
|
||||
printf '> - `%s` is already defined in %s other file(s): %s\n' "$label" "$count" "$files"
|
||||
done
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The blind spot made visible (#2932). Both write-path hooks fail OPEN when the
|
||||
# instance is slow or down — right for noise, wrong for silence: a session
|
||||
# cannot tell "the ledger checked and found nothing" from "the ledger never
|
||||
# answered", and a self-surfacing system cannot afford an invisible miss (the
|
||||
# first write after a redeploy lost its derive line to a 4s cold start and
|
||||
# nobody knew). So a failed call says so — ONCE per outage: the marker holds
|
||||
# the time it last spoke; within ten minutes of that it stays quiet, and a
|
||||
# successful call clears it so the next outage announces itself afresh.
|
||||
# Unconfigured installs never reach this: no URL/token means no call was owed.
|
||||
# Where every hook gets its endpoint and credential. Four lines, and each of
|
||||
# the five hooks carried its own copy until #2278 — which is exactly the
|
||||
# missing-sibling shape: the `${...}` guard below is a correctness detail a
|
||||
# sixth hook would have forgotten, and nothing would have failed loudly.
|
||||
#
|
||||
# Sets `url` and `token` as globals rather than echoing them: a token must not
|
||||
# pass through a subshell's output, where it could land in a log or an `xtrace`
|
||||
# line. Returns 0 only when both are usable, so a caller can either bail
|
||||
# (`scribe_config || exit 0`) or carry on degraded — the session-context hook
|
||||
# still owes its static floor when Scribe is unconfigured.
|
||||
# Declared here, not just assigned inside the function: `scribe_defs.sh` owns
|
||||
# these two names, and a sourcing hook should have them defined the moment it
|
||||
# sources — before any code path that might reference them. It also lets
|
||||
# the linter see the assignment, which it cannot follow into a function in
|
||||
# another file without -x (SC2154).
|
||||
url=""
|
||||
token=""
|
||||
|
||||
scribe_config() {
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
# An unexpanded `${...}` placeholder arriving as a literal would be sent as a
|
||||
# garbage Bearer token and 401. Treat it as unset.
|
||||
case "$url" in *'${'*) url="" ;; esac
|
||||
case "$token" in *'${'*) token="" ;; esac
|
||||
[ -n "$url" ] && [ -n "$token" ]
|
||||
}
|
||||
|
||||
_SCRIBE_UNREACHED_QUIET=600
|
||||
|
||||
scribe_unreached() {
|
||||
local marker="$1/$2.unreached" now last
|
||||
now=$(date +%s 2>/dev/null) || now=0
|
||||
if [ -f "$marker" ]; then
|
||||
last=$(cat "$marker" 2>/dev/null) || last=0
|
||||
case "$last" in ''|*[!0-9]*) last=0 ;; esac
|
||||
[ $((now - last)) -lt "$_SCRIBE_UNREACHED_QUIET" ] && return 0
|
||||
fi
|
||||
printf '%s' "$now" > "$marker" 2>/dev/null || true
|
||||
printf '> Scribe did not answer the prior-art check for `%s` within %ss — this write went UNCHECKED against the record and the shape ledger (the local by-name arm, if it spoke above, needed no server). If the name matters, check it yourself: `search` for the concept, `list_shapes(project_id, path=…)` for the ledger. Said once per outage; if it keeps happening the instance is slow or down.' "$4" "$3"
|
||||
}
|
||||
|
||||
scribe_reached() {
|
||||
rm -f "$1/$2.unreached" 2>/dev/null || true
|
||||
}
|
||||
@@ -51,14 +51,12 @@ code=$(printf '%s' "$event" | jq -r '
|
||||
.tool_input.content // .tool_input.file_content //
|
||||
.tool_input.new_string // .tool_input.new_str // empty' 2>/dev/null) || code=""
|
||||
|
||||
# Skip formats that hold prose or data rather than reusable code. Purely to
|
||||
# avoid a pointless round-trip — the server would return nothing for these
|
||||
# anyway. Config formats are NOT skipped: a CI workflow or a compose file is
|
||||
# often exactly the thing worth reusing.
|
||||
case "$file_path" in
|
||||
*.md|*.mdx|*.txt|*.rst|*.json|*.lock|*.log|*.csv|*.tsv|*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf)
|
||||
exit 0 ;;
|
||||
esac
|
||||
# Shared with the after-write hook (#2901): the prose/data skip list, the
|
||||
# definition extractor and the local by-name duplicate arm live in
|
||||
# scribe_defs.sh so the two hooks cannot drift apart.
|
||||
# shellcheck source=plugin/hooks/scribe_defs.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
||||
scribe_skip_path "$file_path" && exit 0
|
||||
|
||||
# Snippet locations are recorded repo-relative, so send a repo-relative path —
|
||||
# an absolute one would simply match nothing. Resolved BEFORE the config gate
|
||||
@@ -73,78 +71,8 @@ if [ -n "$repo_root" ]; then
|
||||
esac
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist?
|
||||
#
|
||||
# The other two arms ask Scribe what was RECORDED. Scribe has never read a line
|
||||
# of the codebase, so a helper nobody thought to record is invisible to them —
|
||||
# which is how `.btn-primary` came to be defined four times, in four scoped
|
||||
# stylesheets, already diverged. It was never a snippet, so no threshold and no
|
||||
# query rewrite could ever have surfaced it.
|
||||
#
|
||||
# This arm closes that by asking the only question the record cannot answer,
|
||||
# in the only place that can: the hook already runs on the developer's machine,
|
||||
# inside the repo, holding the code about to be written. No index, no storage,
|
||||
# no staleness, and no server — it deliberately runs even on an install that
|
||||
# has never configured Scribe.
|
||||
#
|
||||
# Definition-shaped patterns only. Grepping for bare occurrences would match
|
||||
# every CALL site and drown the real finding — and a hint that is mostly noise
|
||||
# is one people learn to skip, which is worse than none.
|
||||
#
|
||||
# ALL code, not a language shortlist (#2682): the detector was born covering
|
||||
# only the languages of the repo it was written in, which silently amputated
|
||||
# this whole arm — and the record nudge gated on it — for every Go/Kotlin/Rust
|
||||
# project. Definitions are announced by a small keyword family across
|
||||
# languages (func/fun/fn/function/def/sub · class/struct/trait/interface/
|
||||
# enum/object/protocol/type), so one modifier-strip + keyword match covers
|
||||
# them all. Known out of scope: keyword-less declaration syntax (C/Java/Dart
|
||||
# `ReturnType name(...)`) needs a real parser, and `impl` blocks are excluded
|
||||
# because several per type is normal Rust, not duplication.
|
||||
# ---------------------------------------------------------------------------
|
||||
# kind<TAB>name for each thing a piece of code DEFINES, in source order. One
|
||||
# program, two consumers: the local duplicate arm (every definition in the
|
||||
# payload) and the ledger feed (#2791, below: the definitions being written,
|
||||
# or the one enclosing an Edit). Rule-for-rule mirrored by the server's
|
||||
# services/coverage.py extract_shapes — ledger rows are keyed by what THAT
|
||||
# sees, so the two must agree on what counts as a definition.
|
||||
scribe_defs() {
|
||||
awk '
|
||||
{
|
||||
# CSS class definition: .name { or .name,
|
||||
if (match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/)) {
|
||||
t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t)
|
||||
if (t != "") print "css\t" t; next
|
||||
}
|
||||
line = $0; sub(/^[[:space:]]+/, "", line)
|
||||
# Strip leading declaration modifiers so the definition keyword is the
|
||||
# first word regardless of language (export/pub/private/suspend/...).
|
||||
sub(/^((pub(\([a-z]+\))?|export|default|private|internal|protected|public|static|suspend|async|open|sealed|data|abstract|final|inline|unsafe|extern|override)[[:space:]]+)*/, "", line)
|
||||
# Go method with receiver: func (r *T) Name(
|
||||
if (match(line, /^func[[:space:]]*\([^)]*\)[[:space:]]*[A-Za-z_]/)) {
|
||||
t = line; sub(/^func[[:space:]]*\([^)]*\)[[:space:]]*/, "", t)
|
||||
sub(/[^A-Za-z0-9_].*$/, "", t)
|
||||
if (t != "") print "sym\t" t; next
|
||||
}
|
||||
# Keyword-announced definitions, functions and named types alike.
|
||||
# Dunders are skipped: every class defines __init__, so "already defined
|
||||
# in N other files" is guaranteed noise for them — and noise is what
|
||||
# teaches sessions to skip the hint.
|
||||
if (match(line, /^(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+[A-Za-z_$]/)) {
|
||||
t = line; sub(/^[a-z]+[[:space:]]+/, "", t)
|
||||
sub(/[^A-Za-z0-9_$].*$/, "", t)
|
||||
if (t != "" && t !~ /^__.*__$/) print "sym\t" t; next
|
||||
}
|
||||
# Arrow/expression assignment: const name = (…) / let name = async (
|
||||
if (match(line, /^(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/)) {
|
||||
t = line; sub(/^(const|let)[[:space:]]+/, "", t)
|
||||
sub(/[^A-Za-z0-9_$].*$/, "", t)
|
||||
if (t != "") print "sym\t" t; next
|
||||
}
|
||||
}
|
||||
' 2>/dev/null
|
||||
}
|
||||
|
||||
# ARM 1 — BY NAME, LOCALLY (#2280): does a definition of this already exist
|
||||
# in the repo? (scribe_local_dups in scribe_defs.sh carries the why.)
|
||||
names=""
|
||||
if [ -n "$code" ]; then
|
||||
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12) || names=""
|
||||
@@ -152,21 +80,8 @@ fi
|
||||
|
||||
local_lines=""
|
||||
if [ -n "$repo_root" ] && [ -n "$names" ]; then
|
||||
while IFS=$'\t' read -r kind name; do
|
||||
[ -n "${name:-}" ] || continue
|
||||
case "$kind" in
|
||||
css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;;
|
||||
*) pat="(function|def|class|func|fun|fn|sub|struct|trait|interface|enum|object|protocol|type)[[:space:]]+${name}[^A-Za-z0-9_]|func[[:space:]]*\([^)]*\)[[:space:]]*${name}[[:space:]]*\(|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
|
||||
esac
|
||||
# -I skips binaries; :(exclude) drops the file being written, which would
|
||||
# otherwise always match itself on an Edit.
|
||||
hits=$(git -C "$repo_root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel_path}" 2>/dev/null | head -4) || hits=""
|
||||
[ -n "$hits" ] || continue
|
||||
count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0)
|
||||
label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name")
|
||||
files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//')
|
||||
local_lines="${local_lines}> - \`${label}\` is already defined in ${count} other file(s): ${files}"$'\n'
|
||||
done <<< "$names"
|
||||
local_lines=$(scribe_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
|
||||
[ -n "$local_lines" ] && local_lines="${local_lines}"$'\n'
|
||||
fi
|
||||
|
||||
local_context=""
|
||||
@@ -206,11 +121,7 @@ if [ -n "$shapes" ]; then
|
||||
[ -n "$enc" ] && shapes_q="&shapes=${enc}"
|
||||
fi
|
||||
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
||||
case "$url" in *'${'*) url="" ;; esac
|
||||
case "$token" in *'${'*) token="" ;; esac
|
||||
scribe_config || : # sets url/token; unconfigured is handled just below
|
||||
# Unconfigured install → the recorded-prior-art arms are skipped, but the local
|
||||
# arm above already ran and may have something to say.
|
||||
if [ -z "$url" ] || [ -z "$token" ]; then
|
||||
@@ -258,14 +169,29 @@ fi
|
||||
# the sync nudge when the recorded file itself is edited later.
|
||||
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
||||
mkdir -p "$state_dir" 2>/dev/null || true
|
||||
#
|
||||
# A THIRD channel (#2900): the ledger's derive arm names a duplicate family
|
||||
# (a derive group id) or a canon elsewhere (`canon:<snippet_id>`) for the
|
||||
# shapes being written. Keyed by that token, not a note id, so it dedups on
|
||||
# its own file and a family is named once per session, not at every edit.
|
||||
#
|
||||
# A FOURTH channel (milestone 307): standing RULES the write resembles. Its own
|
||||
# file for the same reason as the others — a rule named once should not be
|
||||
# re-offered on every subsequent write in the session.
|
||||
idfile=""
|
||||
syncfile=""
|
||||
derivefile=""
|
||||
rulefile=""
|
||||
exclude_q=""
|
||||
sync_exclude_q=""
|
||||
derive_exclude_q=""
|
||||
rule_exclude_q=""
|
||||
if [ -n "$session_id" ]; then
|
||||
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
||||
idfile="$state_dir/${safe_sid}.ids"
|
||||
syncfile="$state_dir/${safe_sid}.sync.ids"
|
||||
derivefile="$state_dir/${safe_sid}.derive.ids"
|
||||
rulefile="$state_dir/${safe_sid}.rules.ids"
|
||||
if [ -f "$idfile" ]; then
|
||||
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
||||
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
||||
@@ -274,13 +200,30 @@ if [ -n "$session_id" ]; then
|
||||
sync_seen=$(tr '\n' ',' < "$syncfile" 2>/dev/null | sed 's/,$//')
|
||||
[ -n "$sync_seen" ] && sync_exclude_q="&exclude_sync_ids=${sync_seen}"
|
||||
fi
|
||||
if [ -f "$derivefile" ]; then
|
||||
derive_seen=$(tr '\n' ',' < "$derivefile" 2>/dev/null | sed 's/,$//' | jq -sRr '@uri' 2>/dev/null) || derive_seen=""
|
||||
[ -n "$derive_seen" ] && derive_exclude_q="&exclude_derive=${derive_seen}"
|
||||
fi
|
||||
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
|
||||
|
||||
# `|| true`, not `|| exit 0`: an unreachable instance must not discard a local
|
||||
# finding that needed no instance to produce.
|
||||
# Not `|| exit 0`: an unreachable instance must not discard a local finding
|
||||
# that needed no instance to produce. And not silence either (#2932): a call
|
||||
# that was owed and didn't come back is said, once per outage, so the session
|
||||
# knows this write went unchecked.
|
||||
reached=1
|
||||
body=$(curl -fsS --max-time 5 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${shapes_q}" 2>/dev/null) || body=""
|
||||
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${derive_exclude_q}${rule_exclude_q}${shapes_q}" 2>/dev/null) || { body=""; reached=0; }
|
||||
unreached_context=""
|
||||
if [ "$reached" = 1 ]; then
|
||||
scribe_reached "$state_dir" "${safe_sid:-nosession}"
|
||||
else
|
||||
unreached_context=$(scribe_unreached "$state_dir" "${safe_sid:-nosession}" 5 "$rel_path")
|
||||
fi
|
||||
|
||||
context=""
|
||||
if [ -n "$body" ]; then
|
||||
@@ -295,6 +238,12 @@ if [ -n "$body" ]; then
|
||||
if [ -n "$syncfile" ]; then
|
||||
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
|
||||
fi
|
||||
if [ -n "$rulefile" ]; then
|
||||
printf '%s' "$body" | jq -r '(.rule_ids // [])[]?' 2>/dev/null >> "$rulefile" || true
|
||||
fi
|
||||
if [ -n "$derivefile" ]; then
|
||||
printf '%s' "$body" | jq -r '(.derive_keys // [])[]?' 2>/dev/null >> "$derivefile" || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -304,9 +253,10 @@ fi
|
||||
# noise: the duplication is demonstrated, not guessed. Gated on BOTH sides so
|
||||
# an ordinary new helper (no other copies) and an already-recorded one (the
|
||||
# server spoke) stay nudge-free — a reflex that fires on everything is one
|
||||
# that gets skipped. An unreachable server counts as "nothing recorded": the
|
||||
# local finding needed no server, and the nudge fails open with it.
|
||||
if [ -n "$local_lines" ]; then
|
||||
# that gets skipped. A server that did not ANSWER earns no nudge (#2932): "none
|
||||
# of those copies is recorded" is a claim only an answer can back — the
|
||||
# unreached line says what actually happened instead.
|
||||
if [ -n "$local_lines" ] && [ "$reached" = 1 ]; then
|
||||
n_recorded=$(printf '%s' "$body" | jq -r '.note_ids | length' 2>/dev/null) || n_recorded=0
|
||||
if [ "${n_recorded:-0}" = "0" ] || [ "$n_recorded" = "" ]; then
|
||||
local_context="${local_context}"$'\n'"> None of those existing copies is recorded in Scribe. If the version being written is the canonical one — or this edit is consolidating the copies — record it now with create_snippet (name, code, when-to-reach-for-it, location) so the next session is offered it instead of writing another copy."
|
||||
@@ -321,6 +271,10 @@ if [ -n "$context" ]; then
|
||||
[ -n "$combined" ] && combined="${combined}"$'\n'
|
||||
combined="${combined}${context}"
|
||||
fi
|
||||
if [ -n "$unreached_context" ]; then
|
||||
[ -n "$combined" ] && combined="${combined}"$'\n'
|
||||
combined="${combined}${unreached_context}"
|
||||
fi
|
||||
[ -n "$combined" ] || exit 0
|
||||
|
||||
# No permissionDecision: this is a nudge, not a gate. The write goes ahead.
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
# allowed to fail quietly; see the #2198 comment at the status block below.
|
||||
set -uo pipefail
|
||||
|
||||
# shellcheck source=plugin/hooks/scribe_defs.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
||||
|
||||
command -v jq >/dev/null 2>&1 || exit 0 # needed to emit the JSON envelope safely
|
||||
|
||||
# `CDPATH= cd` is deliberate, not a typo'd assignment: it runs this one `cd`
|
||||
@@ -87,13 +90,9 @@ if [ -f "$manifest" ]; then
|
||||
fi
|
||||
|
||||
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
|
||||
# Guard against an unexpanded `${...}` placeholder reaching us as a literal — it
|
||||
# would otherwise be sent as a garbage Bearer token and 401. Treat as unset.
|
||||
case "$url" in *'${'*) url="" ;; esac
|
||||
case "$token" in *'${'*) token="" ;; esac
|
||||
# Unconfigured is NOT a failure here: tier 1's static floor is still owed,
|
||||
# so this records the answer rather than acting on it.
|
||||
scribe_config || :
|
||||
|
||||
dyn=""
|
||||
status=""
|
||||
@@ -110,6 +109,28 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
|
||||
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
|
||||
# Stash the rules marker for the write-path hook (milestone 323). THIS is
|
||||
# where it has to be captured: the model receives one from
|
||||
# list_always_on_rules too, but a hook cannot see an MCP tool's result. Stored
|
||||
# under the same state dir the prior-art hook already uses, keyed by session,
|
||||
# so "changed since" means since THIS session loaded its rules.
|
||||
#
|
||||
# Written on `compact` as well as `startup`, and that is correct rather than
|
||||
# convenient: a compact tells the session to re-pull its rules, so the marker
|
||||
# should describe the set it is about to hold. It is also why this cannot
|
||||
# cover the compaction case — see the table in services/plugin_context.py.
|
||||
if [ -n "$body" ]; then
|
||||
etag=$(printf '%s' "$body" | jq -r '.rules_etag // empty' 2>/dev/null) || etag=""
|
||||
if [ -n "$etag" ]; then
|
||||
sid=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || sid=""
|
||||
safe_sid=$(printf '%s' "${sid:-nosession}" | tr -c 'A-Za-z0-9._-' '_')
|
||||
etag_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
||||
# Best-effort throughout: a marker that cannot be stored costs a hint,
|
||||
# never the session.
|
||||
mkdir -p "$etag_dir" 2>/dev/null \
|
||||
&& printf '%s' "$etag" > "$etag_dir/${safe_sid}.rules_etag" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
[ -z "$dyn" ] && status="> ⚠️ Scribe: live rules/project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\` as needed."
|
||||
elif [ -n "$url" ] && [ -z "$token" ]; then
|
||||
status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
|
||||
|
||||
@@ -21,6 +21,16 @@ for the operator's work, and as your own working memory across sessions.
|
||||
compaction — call `list_always_on_rules()` (and `enter_project()` when a
|
||||
project is in scope) BEFORE acting. When a loaded rule and a default habit
|
||||
disagree, the rule wins; if no rule speaks to it, ask rather than assume.
|
||||
- **What you loaded is not all of the rules.** Only the always-on tier arrives
|
||||
that way; conditional rules are RETRIEVED, and one you were never handed
|
||||
binds exactly as hard. So before a consequential act, `search` for a rule
|
||||
about it (`content_type="rule"`) rather than concluding from an empty
|
||||
loaded set that nothing applies. "I was not told" is not the same as "there
|
||||
is no rule," and only one of those is checkable.
|
||||
This bites hardest on which TOOL to reach for — curling an API that has an
|
||||
MCP client, standing up a local stack, running a suite CI owns. Those feel
|
||||
like mechanics rather than decisions, so they raise no doubt and generate no
|
||||
query; the moment you are most confident is the moment to look.
|
||||
- **Recall before acting** — before you answer anything about the operator's
|
||||
work or start a task, `search` Scribe first; assume a related note, task, or
|
||||
decision already exists. Concretely, reach for recall whenever a request
|
||||
@@ -66,7 +76,10 @@ for the operator's work, and as your own working memory across sessions.
|
||||
should read as a map of every shape in it. The backstop still holds:
|
||||
noticing the second copy of anything, or consolidating copies into a shared
|
||||
X, means X gets recorded before that work is finished — which is how a
|
||||
codebase is kept from growing four `.btn-primary` definitions.
|
||||
codebase is kept from growing four `.btn-primary` definitions. The write-path
|
||||
hooks (before a Write/Edit, and after any Bash call that changed the tree)
|
||||
name a known duplicate family or a canon elsewhere for what was just
|
||||
written — act on that line at the write, not at the next audit.
|
||||
- Do **not** keep the operator's rules, plans, or project notes in local
|
||||
memory / CLAUDE.md in parallel with Scribe — Scribe holds the single copy.
|
||||
- **Compact at clean seams** — because you record as you go, a context
|
||||
|
||||
@@ -23,15 +23,13 @@
|
||||
# #2198), with SCRIBE_URL / SCRIBE_TOKEN as the override.
|
||||
set -uo pipefail
|
||||
|
||||
# shellcheck source=plugin/hooks/scribe_defs.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
||||
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
command -v curl >/dev/null 2>&1 || exit 0
|
||||
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
# Guard against an unexpanded `${...}` placeholder arriving as a literal.
|
||||
case "$url" in *'${'*) url="" ;; esac
|
||||
case "$token" in *'${'*) token="" ;; esac
|
||||
[ -n "$url" ] && [ -n "$token" ] || exit 0
|
||||
scribe_config || exit 0
|
||||
|
||||
body=$(curl -fsS --max-time 8 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
|
||||
@@ -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
|
||||
@@ -44,6 +44,14 @@ through recall/auto-inject; this skill is the active reflex around that.
|
||||
it before you go any further. Either it's the helper you were about to
|
||||
duplicate — reuse it and drop yours — or it isn't, and the record needs the new
|
||||
location adding. Both are cheaper now than after the duplicate settles in.
|
||||
- **A `Shape ledger at …` line is the ledger speaking, not the record.** It
|
||||
names a duplicate family ("identical body in N other files, no canon"), a
|
||||
repeated name ("defined in N other files") or a canon elsewhere for a name
|
||||
you just wrote — for edits made through Bash
|
||||
(sed, heredocs, scripts) as much as through Write/Edit. Derive the family or
|
||||
reuse the canon *now*; a family that is convention rather than copies is
|
||||
dismissed with `classify_shapes(..., status="exempt",
|
||||
reason_code="convention-plumbing")`, never ignored.
|
||||
- **A `[records this file]` hint is a duty, not a menu.** When the hint says a
|
||||
snippet records the very file you're editing, the record's freshness is now
|
||||
YOUR edit's responsibility: if the edit changes the recorded shape,
|
||||
|
||||
@@ -85,6 +85,55 @@ the dominant form, `create_snippet` it, migrate the outliers, then classify
|
||||
the rest as instances. Canon is determined from the code; consistency comes
|
||||
from the derivation, not from asking permission.
|
||||
|
||||
## Derive groups are drift, not audit material
|
||||
|
||||
The catalogue exists so the codebase is DRY **from inception**, not as DRY as
|
||||
the last sweep left it. Three surfaces say so without anyone running an audit
|
||||
(milestone 299):
|
||||
|
||||
- **At the write** — the prior-art hint (the Write/Edit hook, and since
|
||||
0.1.39 the after-write hook on Bash, so sed/heredoc/script edits count too)
|
||||
carries a `Shape ledger at <path>` line when a name just written is a known
|
||||
**duplicate family** ("identical body in N other files, no canon"), a
|
||||
**repeated name** ("defined in N other files, no canon") or a
|
||||
**canon elsewhere** ("snippet #N at <path> — reuse, don't redefine"). Act
|
||||
on it *then*: pull the canon and build from it, or derive the family now —
|
||||
`create_snippet` the dominant form, repoint the copies, `classify_shapes`
|
||||
them `instance`. A family is named once per session.
|
||||
- **On arrival** — the coverage line's `standing:` block (shown even when
|
||||
nothing is unclassified) and `derive_new` ("+N new copies since last
|
||||
refresh: .x in <path>") name what drifted since the previous refresh. That
|
||||
is the todo of the moment, sized to the last batch — not a backlog.
|
||||
- **A family that is convention, not copies** — component-local `load` /
|
||||
`toggle` / `save` that happen to share a name — is dismissed, not
|
||||
consolidated: `classify_shapes(..., status="exempt",
|
||||
reason_code="convention-plumbing", reason=…)` (or `classify_shapes_by_rule`
|
||||
for a whole family) removes it from the queue. Dismissal is a judgment and
|
||||
it is recorded; silence is not.
|
||||
- **CSS is watched by name, never by body** (note 2917). Classes serving
|
||||
different purposes share declarations because the style system makes them
|
||||
alike — `.text-muted` and `.pin-badge-auto` carrying the same `color:` are
|
||||
two meanings, not two copies — so a CSS family is the *same class defined
|
||||
in ≥2 files* (a recipe living in several places), and identical bodies
|
||||
under different names are never a family. Derive a CSS family by moving
|
||||
the recipe to the shared sheet and recording it; a class name reused for
|
||||
genuinely different things is dismissed with `reason_code="scoped-css"`.
|
||||
The datum that decides between the two is **what renders it**: every css
|
||||
row carries `used_by` (the files whose markup names the class — the CSS
|
||||
consumer map, milestone 302), a derive group carries the family's
|
||||
`consumers`, and the write-path line says "used by N template(s)". Many
|
||||
templates, one recipe → derive; one template each, different purposes →
|
||||
dismiss. `list_shapes(flag="unused-css")` is the map's negative space —
|
||||
css rules no template names, a deletion candidate to look at, never
|
||||
auto-deleted. The map reads the two class forms templates don't spell out
|
||||
— a `<Transition name="x">`'s generated classes, and the prefix of a
|
||||
concatenated name (`` `status-${s}` `` credits every `status-…` rule) —
|
||||
so what it flags is worth reading. What it still cannot see is a name
|
||||
assembled in a script (`classList.add`), so confirm before deleting.
|
||||
|
||||
After the one-time pay-down the derive queue reads empty; anything in it
|
||||
afterwards is drift of the moment, and the hint already said so at the write.
|
||||
|
||||
## The divergence readout — button B where button A is canon
|
||||
|
||||
Three questions the ledger answers mechanically (#2793):
|
||||
|
||||
@@ -56,10 +56,31 @@ Two constraints on *how* that's achieved:
|
||||
re-deriving it or opening a duplicate. When a project is in scope, pass its
|
||||
`project_id` so results stay scoped.
|
||||
|
||||
2. **Standing rules are binding.** Load them via `list_always_on_rules()` at
|
||||
session start (see "Do this first"); treat every one as binding. Pull a
|
||||
rule's full statement with `get_rule(id)` when it's about to bite. When a
|
||||
project is in scope, `enter_project(id)` also returns its applicable rules.
|
||||
2. **Standing rules are binding — and the ones you were handed are not all of
|
||||
them.** Load the resident set via `list_always_on_rules()` at session start
|
||||
(see "Do this first"); treat every one as binding. Pull a rule's full
|
||||
statement with `get_rule(id)` when it's about to bite. When a project is in
|
||||
scope, `enter_project(id)` also returns its applicable rules.
|
||||
|
||||
Rules come in two tiers. **Always-on** rules are delivered — they arrive
|
||||
whether or not you ask. **Conditional** rules are RETRIEVED, and one binds
|
||||
just as hard for never having been handed to you. So before a consequential
|
||||
act, `search(content_type="rule")` on what you are about to do. An empty
|
||||
loaded set is not evidence that no rule applies; it is only evidence that
|
||||
none was pushed, and those are different claims.
|
||||
|
||||
The tier split exists because delivery does not scale: every resident rule
|
||||
costs tokens in every session forever, so a rulebook that grows past a few
|
||||
dozen either stops growing or stops fitting. Retrieval is what lets the
|
||||
rulebook keep growing — but retrieval only fires if something asks.
|
||||
|
||||
**Ask hardest where you feel most certain.** Rules about which TOOL to reach
|
||||
for — use the forge's MCP client rather than curling its API, don't stand up
|
||||
a local stack, don't run the suite CI owns — govern moves that feel like
|
||||
mechanics rather than decisions. A reflex raises no doubt, so it generates
|
||||
no query, so the rule that would have stopped it is never retrieved. That is
|
||||
the failure this instruction exists to prevent, and confidence is its only
|
||||
warning sign.
|
||||
|
||||
3. **Update over duplicate.** When recording, prefer updating an existing
|
||||
note/rule/task over creating a new one. Search first; revise what's there.
|
||||
@@ -96,7 +117,25 @@ Two constraints on *how* that's achieved:
|
||||
not restraint. Only a record genuinely about no particular area goes
|
||||
untagged.
|
||||
|
||||
8. **State updates in place; chronicles don't.** A dev-log records what
|
||||
8. **Name the record, never just its number.** Whenever you refer to a Scribe
|
||||
record — in a message to the operator, a commit message, a task body, a
|
||||
work-log — write the id *and* its title: `#3244 "the staleness signal"`,
|
||||
`milestone 323 "rule versioning"`. Not `#3244`.
|
||||
|
||||
You have the record open; the operator does not. A bare id reads as
|
||||
complete to you and as homework to them — they have to look it up to know
|
||||
what their own conversation is about, or guess. Scribe's own duplicate gate
|
||||
already writes `id 412: "debounce helper"` for exactly this reason; match
|
||||
it everywhere else.
|
||||
|
||||
The first mention in a message carries the title; later mentions of the
|
||||
same record can use the bare id. If you don't know the title, look it up
|
||||
before citing the number — an id you can't name is one you haven't checked.
|
||||
This matters most in the places read later by someone with even less
|
||||
context than the operator has now: commit messages, task bodies, and any
|
||||
record that cites another.
|
||||
|
||||
9. **State updates in place; chronicles don't.** A dev-log records what
|
||||
*happened* — write it once, never rewrite it. A durable finding (how a
|
||||
subsystem works, a measured number) lives in that System's **reference
|
||||
note** ("«System» — reference"), which you UPDATE as facts change — safe,
|
||||
@@ -106,6 +145,48 @@ Two constraints on *how* that's achieved:
|
||||
re-measurement, a reversed decision), pass the old id in `supersedes` so the
|
||||
stale record is demoted and labelled rather than left competing.
|
||||
|
||||
10. **A few notes assert a FACT, and those can carry their own check.**
|
||||
Supersession only fires once somebody has read a note and disagreed — which
|
||||
is the case where it was already believed. A note asserting something about
|
||||
*someone else's* software — what a service does on a duplicate upload, how a
|
||||
forge numbers its CI runs, what an updater compares — can instead carry
|
||||
`verify_with` (how to check it) and `expires_when` (the STATE that ends it:
|
||||
"when the forge numbers runs per workflow", never "in six months").
|
||||
`notes_due_for_verification` lists them least-recently-confirmed first, with
|
||||
never-checked at the top; `mark_note_verified` records what you found, and
|
||||
`still_true=False` deliberately writes nothing — a note whose check failed
|
||||
is wrong rather than in a state worth recording, so it keeps its place.
|
||||
|
||||
**The test is one question: could this note become false without anyone
|
||||
editing it?** If no, leave both fields empty. That is the normal case, and
|
||||
an empty `verify_with` is the positive marker for "this is a decision, there
|
||||
is nothing to go and check" — not an unfinished record. The sweep is only
|
||||
worth reading while almost nothing is on it, so a check added out of
|
||||
tidiness costs the whole surface, not just that note.
|
||||
|
||||
**The sharper form of the same test: is the thing this note describes yours
|
||||
to change?** If yes it is a decision — editing your own software is how it
|
||||
changes, and you will know you did it. Measured against a real corpus, every
|
||||
note that earned a check was about somebody ELSE's software: a signing
|
||||
service, a forge, a hub, an SDK, a model, a dependency set.
|
||||
|
||||
**Three that look like candidates and are not:**
|
||||
- **Resume pointers and "current state" notes.** They go stale fastest of
|
||||
anything, which is exactly why they tempt — but the cure is to update or
|
||||
delete them, not to schedule a check. A sweep full of pointers is a sweep
|
||||
nobody reads.
|
||||
- **Measurements of your own system.** They go false because you changed
|
||||
something, and you knew. A measurement earns a check only when what it
|
||||
measures is outside your control.
|
||||
- **A decision that RESTS on somebody else's behaviour.** The decision is
|
||||
still a decision. Put the check on the note asserting the fact, and link
|
||||
the decision to it.
|
||||
|
||||
Not for tasks — a task's decay is its status, and a done issue records what
|
||||
happened rather than asserting something that can go false. Not for snippets
|
||||
either: `verify_snippet` compares the recorded location and code against the
|
||||
repo, which is richer and already wired to drift detection.
|
||||
|
||||
## Stay inside the active project's scope
|
||||
|
||||
Once a project is in scope — you called `enter_project`, or the working repo is
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bump the patch segment of fable-mcp/pyproject.toml version and stage the file.
|
||||
# Usage: called automatically by the Claude Code pre-commit hook, or manually.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
FILE="$REPO_ROOT/fable-mcp/pyproject.toml"
|
||||
|
||||
current=$(grep '^version = ' "$FILE" | sed 's/version = "\(.*\)"/\1/')
|
||||
major=$(echo "$current" | cut -d. -f1)
|
||||
minor=$(echo "$current" | cut -d. -f2)
|
||||
patch=$(echo "$current" | cut -d. -f3)
|
||||
new_version="$major.$minor.$((patch + 1))"
|
||||
|
||||
sed -i "s/^version = \"$current\"/version = \"$new_version\"/" "$FILE"
|
||||
git -C "$REPO_ROOT" add "$FILE"
|
||||
echo "fable-mcp: $current → $new_version"
|
||||
@@ -90,6 +90,59 @@ def style_source(path: pathlib.Path) -> str:
|
||||
return CSS_COMMENT.sub(" ", css)
|
||||
|
||||
|
||||
# A rule that paints text with a colour token AND its own -bg tint of the same
|
||||
# token. The pair looks harmonious and is close to illegible: a 12% tint of a
|
||||
# hue sits near the surface, so the hue as text on it lands around 2:1 against
|
||||
# an AA floor of 4.5. Measured across the whole Scribe ladder in 2026-08:
|
||||
# every one of the six pairs failed on the dark palette, worst 1.60:1.
|
||||
#
|
||||
# The fix is always the same and always available — the token's `-fg` sibling,
|
||||
# which is the hue mixed toward --fs-text-primary far enough to clear AA. So
|
||||
# this FAILS rather than reports: unlike a raw literal, there is nothing to
|
||||
# weigh up.
|
||||
SAME_TOKEN_PAIR = re.compile(
|
||||
r"color\s*:\s*var\(\s*(--fs-[\w-]+?)\s*\)" # color: var(--fs-X)
|
||||
r"|background(?:-color)?\s*:\s*var\(\s*(--fs-[\w-]+?)-bg\s*\)"
|
||||
)
|
||||
|
||||
|
||||
def same_hue_text_on_tint(css: str) -> tuple[list[str], list[str]]:
|
||||
"""Tokens used as TEXT on a tint of themselves, within one rule block.
|
||||
|
||||
TWO SPELLINGS of the same background, because the first version of this
|
||||
check only knew the first and missed four live instances:
|
||||
|
||||
background: var(--fs-X-bg) the token
|
||||
background: color-mix(in srgb, var(--fs-X) N%, transparent) inline
|
||||
|
||||
The inline form is what the project-status pills used, and it is the more
|
||||
dangerous of the two — it does not even name a `-bg` token, so nothing
|
||||
about it looks like the pattern until you measure it.
|
||||
|
||||
Returned separately because they were paid down separately — the token
|
||||
form first (7 badge pairs), then the inline form (46 sites across 18
|
||||
files, 26 of them --fs-accent). Both are clean now, so BOTH gate. The
|
||||
split is kept because the two spellings need different error text: one
|
||||
names a -bg token you can search for, the other names nothing at all.
|
||||
"""
|
||||
token_form, inline_form = [], []
|
||||
for body in re.findall(r"\{([^{}]*)\}", css):
|
||||
# (?<![-\w]) or `border-color`, `border-left-color` and `outline-color`
|
||||
# all match as if they were text. They are not: a border is a non-text
|
||||
# graphic and its floor is 3:1, not 4.5. Without this the check reported
|
||||
# seven rules that were already correct — and a check that cries wolf on
|
||||
# correct code is one that gets muted.
|
||||
fg = set(re.findall(r"(?<![-\w])color\s*:\s*var\(\s*(--fs-[\w-]+?)\s*\)", body))
|
||||
bg_tok = set(re.findall(r"background(?:-color)?\s*:\s*var\(\s*(--fs-[\w-]+?)-bg\s*\)", body))
|
||||
bg_inl = set(re.findall(
|
||||
r"background(?:-color)?\s*:\s*color-mix\([^;]*?var\(\s*(--fs-[\w-]+?)\s*\)[^;]*?\)",
|
||||
body,
|
||||
))
|
||||
token_form.extend(sorted(fg & bg_tok))
|
||||
inline_form.extend(sorted(fg & (bg_inl - bg_tok)))
|
||||
return token_form, inline_form
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--sheet", default="frontend/src/assets/theme.css")
|
||||
@@ -117,6 +170,8 @@ def main() -> int:
|
||||
)
|
||||
|
||||
unresolved: list[tuple[pathlib.Path, str]] = []
|
||||
same_hue_hits: list[tuple[pathlib.Path, str]] = []
|
||||
inline_tint_hits: list[tuple[pathlib.Path, str]] = []
|
||||
superseded_hits: list[tuple[pathlib.Path, str, str]] = []
|
||||
literal_count = 0
|
||||
|
||||
@@ -140,6 +195,12 @@ def main() -> int:
|
||||
|
||||
literal_count += len(HEX_LITERAL.findall(css))
|
||||
|
||||
tok_hits, inl_hits = same_hue_text_on_tint(css)
|
||||
for tok in tok_hits:
|
||||
same_hue_hits.append((path, tok))
|
||||
for tok in inl_hits:
|
||||
inline_tint_hits.append((path, tok))
|
||||
|
||||
if unresolved:
|
||||
print(f"FAIL — {len(unresolved)} unresolvable var() reference(s).")
|
||||
print(" These render as the fallback if given one, or as nothing at all.")
|
||||
@@ -162,12 +223,37 @@ def main() -> int:
|
||||
print(f" {path}: {literal} -> {token}")
|
||||
print()
|
||||
|
||||
if same_hue_hits:
|
||||
print(f"FAIL — {len(same_hue_hits)} rule(s) paint text with a token on a "
|
||||
f"tint of that same token.")
|
||||
print(" A 12% tint sits near the surface, so the hue as text on it lands "
|
||||
"around 2:1 against AA's 4.5.")
|
||||
print(" Use the token's -fg sibling, which is mixed toward "
|
||||
"--fs-text-primary until it clears the floor.\n")
|
||||
for path, tok in same_hue_hits:
|
||||
print(f" {path}: color: var({tok}) on var({tok}-bg) -> var({tok}-fg)")
|
||||
print()
|
||||
else:
|
||||
print("OK — no text painted with a token on a tint of its own -bg.\n")
|
||||
|
||||
if inline_tint_hits:
|
||||
print(f"FAIL — {len(inline_tint_hits)} rule(s) paint text with a token on an "
|
||||
f"INLINE color-mix tint of that same token.")
|
||||
print(" Identical defect to the block above, spelled without a -bg token —")
|
||||
print(" which is what let it hide: nothing about it LOOKS like the pattern.")
|
||||
print(" Use the token's -fg sibling.\n")
|
||||
for path, tok in inline_tint_hits:
|
||||
print(f" {path}: color: var({tok}) on an inline tint -> var({tok}-fg)")
|
||||
print()
|
||||
else:
|
||||
print("OK — no text painted with a token on an inline tint of itself.\n")
|
||||
|
||||
if args.report_literals:
|
||||
print(f"REPORT — {literal_count} raw colour literal(s) in component CSS.")
|
||||
print(" Advisory: a literal is a value stated outside the system, so it "
|
||||
"cannot follow a palette change.\n")
|
||||
|
||||
return 1 if unresolved else 0
|
||||
return 1 if (unresolved or same_hue_hits or inline_tint_hits) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+325
-53
@@ -13,9 +13,20 @@ separate defects have reached a live install through that path:
|
||||
install, because `plugin.json`'s version wasn't bumped and the installer
|
||||
compares versions to decide whether to refresh its cache.
|
||||
|
||||
The rule for the second one was already written down and was still missed. A
|
||||
written rule that depends on being remembered during a long session is not a
|
||||
control; this is.
|
||||
Both were fixed. The second was fixed TWICE — once by bumping the number, and
|
||||
then properly, by removing the class it came from: `plugin.json`'s version is
|
||||
no longer a value anybody chooses. `scripts/mint_plugin_version.py` derives it
|
||||
from the clock (`make mint-plugin`), and `check_version_is_minted` below fails
|
||||
the lane when shipped content moved and the version did not.
|
||||
|
||||
State exactly what that did and did not remove, because a rationale that
|
||||
overstates its own control is how the control gets trusted past its limit, and
|
||||
because the paragraph this replaces was itself read that way. Gone: having to
|
||||
remember which NUMBER to write, and the whole question of whether a chosen
|
||||
number was the right one. Not gone: the mint still has to be RUN, and
|
||||
forgetting to run it is still possible. What changed is that forgetting is now
|
||||
LOUD — a red lane on the batch that forgot, instead of a silent no-op found
|
||||
weeks later when somebody says "I don't think it updated" (#2220).
|
||||
|
||||
shellcheck and jq are NOT in `ci-python` (verified against CI-runner's Dockerfile
|
||||
and scripts/install-common.sh, not from memory — rule #37). CI installs both
|
||||
@@ -31,8 +42,15 @@ itself loudly, because a check that quietly no-ops is the failure mode this
|
||||
whole file exists to prevent.
|
||||
|
||||
Usage:
|
||||
python3 scripts/check_plugin.py # all checks
|
||||
python3 scripts/check_plugin.py --no-version # skip the bump check
|
||||
python3 scripts/check_plugin.py # all checks
|
||||
python3 scripts/check_plugin.py --no-version # on `main` only — see below
|
||||
|
||||
`--no-version` exists for ONE case. The version is measured against
|
||||
`origin/main`, so on `main` itself the comparison is against itself and answers
|
||||
nothing; the syntax, pattern and marker checks are the only ones that mean
|
||||
anything there. It is NOT a way past a red lane — see
|
||||
`check_version_is_minted`, whose whole design is shaped by keeping this flag
|
||||
out of anyone's muscle memory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -43,16 +61,90 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
# The shape contract is ONE definition, shared with the script that mints it —
|
||||
# a checker carrying its own copy of the format would drift from the minter
|
||||
# and pass values the minter can no longer produce. Explicit path insert
|
||||
# because this file runs both as `python3 scripts/check_plugin.py` (which puts
|
||||
# `scripts/` on the path, not the root) and as an import from the test suite.
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from scripts.mint_plugin_version import VERSION_RE # noqa: E402
|
||||
|
||||
PLUGIN_DIR = ROOT / "plugin"
|
||||
HOOKS_DIR = PLUGIN_DIR / "hooks"
|
||||
MANIFEST = PLUGIN_DIR / ".claude-plugin" / "plugin.json"
|
||||
|
||||
# Paths whose contents reach an install. Keep in step with the workflow's
|
||||
# `paths:` filter — a path that ships but isn't checked here is the gap again.
|
||||
SHIPPED = ("plugin", ".claude-plugin")
|
||||
# ── What ships, and what decides what it says about itself ─────────────────
|
||||
#
|
||||
# ONE definition (#3127 §3, milestone 334 step 2). It has TWO consumers that
|
||||
# need different granularities, and conflating them is the bug:
|
||||
#
|
||||
# the workflow's `paths:` trigger whole paths should CI run at all?
|
||||
# the version check paths MINUS should the version
|
||||
# the manifest have moved?
|
||||
# `version`
|
||||
#
|
||||
# The second one is why this is not just a tuple of paths. `plugin.json` lives
|
||||
# INSIDE `plugin/`, so a version bump is itself a change to the shipped set —
|
||||
# and a check that reads the set naively then treats the bump as its own
|
||||
# justification. Any bump passes, no bump fails, and it has proved nothing.
|
||||
# `shipped_content_changed` below is the exclusion-aware reader.
|
||||
#
|
||||
# The exclusion is that ONE FIELD, never the whole file: `plugin.json` also
|
||||
# carries description, mcpServers and userConfig, all of which reach an
|
||||
# install and all of which matter. Excluding the file wholesale would mean a
|
||||
# userConfig-only edit computes an unchanged version and never refreshes —
|
||||
# #2209 again with a narrower trigger.
|
||||
SHIPPED_PATHS = ("plugin", ".claude-plugin")
|
||||
|
||||
# Files that decide what a published artifact SAYS ABOUT ITSELF — kept as a
|
||||
# table so the next artifact is a one-line addition rather than a third
|
||||
# bespoke guard (#3127 §3). The membership test is NOT "is this copied into
|
||||
# the artifact?" but "can changing this file change the published bytes, or
|
||||
# what the artifact says about itself?" — FC learned that twice in four days
|
||||
# (#3156, #3202), and a deriver is never in the COPY list.
|
||||
#
|
||||
# Note what is absent: a CHECKER does not belong here. Whatever validates a
|
||||
# version decides whether the lane goes red, not what any artifact reports,
|
||||
# so `check_plugin.py` itself is not a deriver, while the script that mints
|
||||
# the plugin version is.
|
||||
DERIVERS: dict[str, tuple[str, ...]] = {
|
||||
# The "Generate image tags and version" step computes the server image's
|
||||
# name, ordering key and channel (#3298).
|
||||
".forgejo/workflows/ci.yml": ("server-image",),
|
||||
# Decides the plugin's version FORMAT, so it decides what every future
|
||||
# manifest says about itself (milestone 334 step 3).
|
||||
"scripts/mint_plugin_version.py": ("plugin",),
|
||||
}
|
||||
|
||||
|
||||
def version_relevant_paths() -> tuple[str, ...]:
|
||||
"""Everything a change to which must produce a NEW plugin version.
|
||||
|
||||
Wider than `SHIPPED_PATHS`, and #3127 §3's asymmetry is why it has to be:
|
||||
|
||||
A change to how the VERSION is computed is compared against nothing at
|
||||
all. Left out, the published artifact goes on reporting the OLD value
|
||||
indefinitely.
|
||||
|
||||
Concretely — change the mint script's format string, change nothing else,
|
||||
and a diff over the shipped paths alone reports "no content change, the
|
||||
version need not move". The manifest then keeps a value in the old format
|
||||
forever and nothing ever says so. The mint script reaches no install and
|
||||
belongs here anyway; that is #3156's exact shape.
|
||||
|
||||
A CHECKER is deliberately not here. Whatever validates the version decides
|
||||
whether the lane goes red, not what any artifact reports — so this file is
|
||||
absent from its own set, and that is not an oversight.
|
||||
"""
|
||||
return SHIPPED_PATHS + tuple(
|
||||
path for path, artifacts in DERIVERS.items() if "plugin" in artifacts
|
||||
)
|
||||
|
||||
|
||||
failures: list[str] = []
|
||||
|
||||
@@ -143,8 +235,6 @@ def check_patterns() -> None:
|
||||
ok(f"{rel}: no known-bad patterns")
|
||||
|
||||
|
||||
# --- the version bump ------------------------------------------------------
|
||||
|
||||
# --- shellcheck ------------------------------------------------------------
|
||||
|
||||
def check_shellcheck() -> None:
|
||||
@@ -159,7 +249,12 @@ def check_shellcheck() -> None:
|
||||
return
|
||||
for script in hook_scripts():
|
||||
proc = subprocess.run(
|
||||
[exe, "--severity=warning", "--shell=bash", str(script)],
|
||||
# -x FOLLOWS `# shellcheck source=` directives into the sourced
|
||||
# file. Without it the shared helpers in scribe_defs.sh are
|
||||
# invisible, so every variable they set reads as unassigned
|
||||
# (SC2154) and every bug inside them goes unlinted at the call
|
||||
# site — which is the opposite of what sharing them was for.
|
||||
[exe, "--severity=warning", "--shell=bash", "-x", str(script)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
rel = script.relative_to(ROOT)
|
||||
@@ -172,15 +267,24 @@ def check_shellcheck() -> None:
|
||||
# --- the fail-open contract ------------------------------------------------
|
||||
|
||||
# Every hook promises never to break the operator's session: unconfigured or
|
||||
# unreachable, it exits 0. Three of them additionally promise SILENCE, because
|
||||
# they are pure enrichment. scribe_session_context.sh is the exception by
|
||||
# design — it always emits a static behavioural floor that needs no credentials
|
||||
# and no network, so "silent" would be the wrong assertion for it.
|
||||
# unreachable, it exits 0. Unconfigured, the enrichment hooks are SILENT — no
|
||||
# call was owed. scribe_session_context.sh is the exception by design — it
|
||||
# always emits a static behavioural floor that needs no credentials and no
|
||||
# network, so "silent" would be the wrong assertion for it.
|
||||
#
|
||||
# UNREACHABLE is different for the two write-path hooks since #2932: a call
|
||||
# that was owed and did not come back is SAID, once per outage ("> Scribe did
|
||||
# not answer …"), so a session can tell "checked, nothing there" from "never
|
||||
# checked". That line — or silence, when the once-per-outage marker in
|
||||
# ${TMPDIR:-/tmp}/scribe-priorart/ was set by a run in the last ten minutes —
|
||||
# is the only output allowed with no working instance; anything else is a hook
|
||||
# speaking on data it cannot have.
|
||||
#
|
||||
# This is the contract that made #2198 invisible for weeks, so it is worth
|
||||
# pinning: the bug and the healthy no-results case look identical from outside.
|
||||
# Pinning it does NOT make the failure visible; it makes sure the fail-open
|
||||
# behaviour is deliberate rather than accidental.
|
||||
# pinning: the bug and the healthy no-results case looked identical from
|
||||
# outside. #2932 is what finally makes the failure visible at the write; this
|
||||
# check makes sure the fail-open behaviour stays deliberate rather than
|
||||
# accidental.
|
||||
# A symbol that exists nowhere, ASSEMBLED rather than written literally.
|
||||
# The prior-art hook's local arm (#2280) fires with no credentials, so the
|
||||
# silence assertion below needs a name the repo genuinely lacks. Two traps,
|
||||
@@ -201,12 +305,36 @@ SMOKE_EVENTS: dict[str, str] = {
|
||||
"tool_input": {"file_path": "src/x.py",
|
||||
"new_string": f"def {_ABSENT_SYM}():\n pass\n"}}
|
||||
),
|
||||
# The pre-tool rule arm (#3476). A real Bash call, and one whose whole
|
||||
# point is that it looks harmless: reaching for curl against the forge API
|
||||
# is the reflex the arm exists to catch. With no instance it must stay
|
||||
# SILENT — it is deliberately not an OUTAGE_SPEAKER, because a Bash call is
|
||||
# not occasional and an outage line before every command gets the channel
|
||||
# muted.
|
||||
"scribe_tool_rules.sh": json.dumps(
|
||||
{"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
|
||||
"tool_input": {"command": "curl -s https://example.invalid/api/v1/runs"}}
|
||||
),
|
||||
"scribe_sync_processes.sh": json.dumps({"source": "startup"}),
|
||||
"scribe_session_context.sh": json.dumps({"source": "startup"}),
|
||||
# The after-write hook (#2901) diffs the working tree; on CI's clean
|
||||
# checkout there is nothing to report, so silence is the right assertion.
|
||||
# (On a dirty local tree with a definition just written it may speak —
|
||||
# that is the hook working, not a failure of the contract.)
|
||||
"scribe_after_write.sh": json.dumps(
|
||||
{"session_id": "smoke", "cwd": ".", "tool_name": "Bash",
|
||||
"tool_input": {"command": "true"}, "tool_response": {}}
|
||||
),
|
||||
# The shared library is sourced, never run; executed bare it defines
|
||||
# functions and exits — silent by construction.
|
||||
"scribe_defs.sh": "",
|
||||
}
|
||||
|
||||
# The one hook that legitimately produces output with no credentials.
|
||||
STATIC_FLOOR = "scribe_session_context.sh"
|
||||
# The hooks that say so when a configured instance does not answer (#2932).
|
||||
OUTAGE_SPEAKERS = {"scribe_prior_art.sh", "scribe_after_write.sh"}
|
||||
OUTAGE_LINE = "> Scribe did not answer the prior-art check"
|
||||
|
||||
|
||||
def _run_hook(script: Path, event: str, env_extra: dict[str, str]) -> subprocess.CompletedProcess:
|
||||
@@ -258,6 +386,17 @@ def check_fail_open() -> None:
|
||||
f"behavioural floor must survive having no credentials")
|
||||
else:
|
||||
ok(f"{rel} [{label}]: exit 0, static floor present")
|
||||
elif out and label == "unreachable" and script.name in OUTAGE_SPEAKERS:
|
||||
# The only thing allowed here is the outage line itself.
|
||||
try:
|
||||
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
||||
except (ValueError, KeyError, TypeError):
|
||||
ctx = ""
|
||||
if ctx.startswith(OUTAGE_LINE):
|
||||
ok(f"{rel} [{label}]: exit 0, says the instance did not answer")
|
||||
else:
|
||||
fail(f"{rel} [{label}]: emitted output with no working instance "
|
||||
f"that is not the outage line:\n {out[:200]}")
|
||||
elif out:
|
||||
fail(f"{rel} [{label}]: emitted output with no working instance:\n"
|
||||
f" {out[:200]}")
|
||||
@@ -352,80 +491,213 @@ def _git(*args: str) -> tuple[int, str]:
|
||||
return proc.returncode, (proc.stdout or proc.stderr).strip()
|
||||
|
||||
|
||||
def manifest_version(ref: str | None = None) -> str | None:
|
||||
"""The manifest version at `ref`, or in the working tree when ref is None."""
|
||||
def manifest_text(ref: str | None = None) -> str | None:
|
||||
"""The manifest's RAW TEXT at `ref`, or in the working tree when ref is None.
|
||||
|
||||
Split out from `manifest_version` because the exclusion below needs every
|
||||
field except one, not the one field.
|
||||
"""
|
||||
if ref is None:
|
||||
try:
|
||||
return json.loads(MANIFEST.read_text()).get("version")
|
||||
except Exception:
|
||||
return MANIFEST.read_text()
|
||||
except OSError:
|
||||
return None
|
||||
rel = MANIFEST.relative_to(ROOT).as_posix()
|
||||
code, out = _git("show", f"{ref}:{rel}")
|
||||
if code != 0:
|
||||
return out if code == 0 else None
|
||||
|
||||
|
||||
def manifest_version(ref: str | None = None) -> str | None:
|
||||
"""The manifest version at `ref`, or in the working tree when ref is None."""
|
||||
text = manifest_text(ref)
|
||||
if text is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(out).get("version")
|
||||
return json.loads(text).get("version")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def check_version_bump(base: str = "origin/main") -> None:
|
||||
"""If shipped plugin content differs from `base`, the version must too.
|
||||
# Distinct from None, which is a legitimate "this manifest does not exist".
|
||||
_UNREADABLE = object()
|
||||
|
||||
Stated against the BASE BRANCH rather than the last commit on purpose. A
|
||||
per-commit rule would demand a bump from every commit in a batch; what
|
||||
actually matters is that whatever reaches an install carries a version the
|
||||
installer can tell apart from the one already cached. One bump per batch,
|
||||
which is also how a human would do it.
|
||||
|
||||
def manifest_differs_beyond_version(a: str | None, b: str | None) -> bool:
|
||||
"""Do two `plugin.json` texts differ in anything OTHER than `version`?
|
||||
|
||||
THE exclusion, and it is kept pure — no git, no filesystem — because this
|
||||
is the half worth testing hard and it needs no repository to exercise.
|
||||
|
||||
Compares PARSED objects rather than text, so reformatting, key reordering
|
||||
and whitespace do not read as content changes. `version` is dropped from
|
||||
both sides; everything else counts, which is what keeps a userConfig-only
|
||||
or mcpServers-only edit demanding a new version.
|
||||
|
||||
Unreadable input answers True. The conservative direction is "demand a new
|
||||
version": a spurious bump costs one cache refresh, while a missed one is
|
||||
#2209 — the fix reaches the repo and stops there.
|
||||
"""
|
||||
def without_version(text: str | None):
|
||||
if text is None:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except Exception:
|
||||
return _UNREADABLE
|
||||
if not isinstance(data, dict):
|
||||
return _UNREADABLE
|
||||
return {k: v for k, v in data.items() if k != "version"}
|
||||
|
||||
left, right = without_version(a), without_version(b)
|
||||
if left is _UNREADABLE or right is _UNREADABLE:
|
||||
return True
|
||||
return left != right
|
||||
|
||||
|
||||
def shipped_content_changed(base: str) -> tuple[bool | None, list[str]]:
|
||||
"""Has anything that REACHES AN INSTALL changed against `base`?
|
||||
|
||||
Returns `(changed, paths)`. `changed` is **None** when the question could
|
||||
not be answered — a caller must never read that as "no", which is the
|
||||
distinction #2663 cost weeks of zeroed telemetry to learn.
|
||||
|
||||
The manifest is special-cased, not excluded: if it is the ONLY thing that
|
||||
moved and the only difference is `version`, nothing that reaches an
|
||||
install has changed. Any other manifest field, or any other file, counts.
|
||||
|
||||
Reads `version_relevant_paths`, which is the shipped set PLUS the files
|
||||
that decide the version — see there for why the deriver has to be in it.
|
||||
"""
|
||||
code, out = _git("diff", "--name-only", base, "--", *version_relevant_paths())
|
||||
if code != 0:
|
||||
return None, []
|
||||
paths = [p for p in out.splitlines() if p.strip()]
|
||||
if not paths:
|
||||
return False, []
|
||||
|
||||
rel_manifest = MANIFEST.relative_to(ROOT).as_posix()
|
||||
if paths == [rel_manifest]:
|
||||
return manifest_differs_beyond_version(
|
||||
manifest_text(), manifest_text(base)
|
||||
), paths
|
||||
return True, paths
|
||||
|
||||
|
||||
def check_version_is_minted(base: str = "origin/main") -> None:
|
||||
"""THE control (#3127 checklist 4), replacing "somebody remembers".
|
||||
|
||||
The checklist asks, of any hand-set component: *say what happens the
|
||||
release somebody forgets it.* This is the answer — the lane goes red,
|
||||
deterministically, because CI can compute whether the value should have
|
||||
moved. Its predecessor could only ask "did the number move at all", which
|
||||
any bump satisfied and which therefore proved nothing.
|
||||
|
||||
Four verdicts:
|
||||
|
||||
content changed, version did not FAIL — this is #2209, exactly
|
||||
version not in canonical shape FAIL — see below
|
||||
version implausibly in the future FAIL — a bad clock or a hand-edit
|
||||
version moved, content did not pass, and say so
|
||||
|
||||
THE LAST ROW IS NOT A FAILURE, DELIBERATELY. A needless re-mint costs one
|
||||
cache refresh and nothing else. Failing the lane over a harmless act is how
|
||||
a check earns a `--no-version` in somebody's muscle memory and stops
|
||||
running at all — which is the failure mode this whole file exists to
|
||||
prevent. The implication that matters is one-directional: content changed
|
||||
IMPLIES version moved.
|
||||
|
||||
A malformed version is worth failing on even though the installer would
|
||||
accept it. `K4` returns the manifest string verbatim, and `H == "unknown"`
|
||||
sets `forceOverwrite`, so a broken value either sorts as a normal string
|
||||
or reinstalls the plugin every single session (#3325). Neither is loud.
|
||||
|
||||
Stated against the BASE BRANCH rather than the last commit, as its
|
||||
predecessor was: a per-commit rule would demand a fresh mint from every
|
||||
commit in a batch, when what matters is that whatever reaches an install
|
||||
differs from what is cached. One mint per batch, which is also how a person
|
||||
would do it.
|
||||
"""
|
||||
code, _ = _git("rev-parse", "--verify", base)
|
||||
if code != 0:
|
||||
# Do NOT pass silently — a check that quietly no-ops is how this class
|
||||
# of bug survives in the first place.
|
||||
fail(
|
||||
f"cannot resolve {base}, so the version-bump check could not run. "
|
||||
f"cannot resolve {base}, so the minted-version check could not run. "
|
||||
f"Fetch it first — `git fetch --depth=1 origin main:refs/remotes/"
|
||||
f"origin/main` is enough, since this diffs two trees and needs no "
|
||||
f"common ancestor — or pass --no-version deliberately."
|
||||
)
|
||||
return
|
||||
|
||||
code, changed = _git("diff", "--name-only", base, "--", *SHIPPED)
|
||||
if code != 0:
|
||||
fail(f"git diff against {base} failed: {changed}")
|
||||
return
|
||||
if not changed.strip():
|
||||
ok(f"no shipped plugin changes against {base} — version bump not required")
|
||||
return
|
||||
|
||||
here, there = manifest_version(), manifest_version(base)
|
||||
here = manifest_version()
|
||||
if here is None:
|
||||
fail(f"could not read a version from {MANIFEST.relative_to(ROOT)}")
|
||||
return
|
||||
if not VERSION_RE.match(here):
|
||||
fail(
|
||||
f"the manifest version is {here!r}, which is not YYYY.MM.DD.HHMM.\n"
|
||||
f" One shape for every version in the family (#3127 checklist "
|
||||
f"10), zero-padded so the midnight case renders 2026.01.05.0000.\n"
|
||||
f" Run `make mint-plugin`."
|
||||
)
|
||||
return
|
||||
|
||||
minted = datetime.strptime(here, "%Y.%m.%d.%H%M").replace(tzinfo=timezone.utc)
|
||||
# A day of slack: the mint happens on a workstation and the lane runs
|
||||
# later, so a *small* skew is ordinary. A value further out than that is
|
||||
# a wrong clock or a typed year, and it makes the version lie about when
|
||||
# it was minted.
|
||||
if minted > datetime.now(timezone.utc) + timedelta(days=1):
|
||||
fail(
|
||||
f"the manifest version {here} is in the future. Either the clock "
|
||||
f"that minted it is wrong, or it was typed by hand."
|
||||
)
|
||||
return
|
||||
|
||||
changed, paths = shipped_content_changed(base)
|
||||
if changed is None:
|
||||
fail(f"git diff against {base} failed, so the version check could not run")
|
||||
return
|
||||
|
||||
there = manifest_version(base)
|
||||
if there is None:
|
||||
ok(f"no manifest on {base} — treating as a new plugin (version {here})")
|
||||
return
|
||||
if here == there:
|
||||
files = "\n ".join(changed.splitlines())
|
||||
|
||||
if changed and here == there:
|
||||
files = "\n ".join(paths)
|
||||
fail(
|
||||
f"plugin content changed but the manifest version is still {here}.\n"
|
||||
f" The installer compares versions to decide whether to refresh "
|
||||
f"its cache, so an unchanged version means these edits reach the repo "
|
||||
f"and stop there — the marketplace clone updates, the cache that "
|
||||
f"actually executes does not (issue #2209).\n"
|
||||
f" Bump `version` in {MANIFEST.relative_to(ROOT)}.\n"
|
||||
f"plugin content changed but the version is still {here}.\n"
|
||||
f" The installer decides whether to refresh its cache by "
|
||||
f"comparing this string, so an unchanged version means these edits "
|
||||
f"reach the repo and stop there — the marketplace clone updates, the "
|
||||
f"cache that actually executes does not (#2209, #1040, #2220).\n"
|
||||
f" Run `make mint-plugin`.\n"
|
||||
f" Changed:\n {files}"
|
||||
)
|
||||
elif changed:
|
||||
ok(f"plugin content changed and the version was minted {there} -> {here}")
|
||||
elif here != there:
|
||||
# Not a failure — see the docstring. Named rather than silent, because
|
||||
# the uninteresting cause (minted twice) and the interesting one (the
|
||||
# version-relevant set is too narrow to see what actually changed)
|
||||
# produce the same line, and only a person can tell them apart.
|
||||
ok(
|
||||
f"the version moved {there} -> {here} with no version-relevant "
|
||||
f"change — harmless, unless something DID change that the set "
|
||||
f"cannot see"
|
||||
)
|
||||
else:
|
||||
ok(f"plugin content changed and version moved {there} -> {here}")
|
||||
ok(f"nothing version-relevant changed against {base} — no mint required")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--no-version", action="store_true",
|
||||
help="skip the manifest version-bump check")
|
||||
help="skip the minted-version check; for `main`, where "
|
||||
"it would be measured against itself")
|
||||
parser.add_argument("--base", default="origin/main",
|
||||
help="branch the version bump is measured against")
|
||||
help="branch the version is measured against")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not HOOKS_DIR.is_dir():
|
||||
@@ -439,7 +711,7 @@ def main() -> int:
|
||||
check_local_prior_art_needs_no_instance()
|
||||
check_session_context_reports_its_version()
|
||||
if not args.no_version:
|
||||
check_version_bump(args.base)
|
||||
check_version_is_minted(args.base)
|
||||
|
||||
print()
|
||||
if failures:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user