Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
c0caf7d23a | ||
|
|
dc2f32cc6f | ||
|
|
10c63f49d8 | ||
|
|
00c7badc3f | ||
|
|
c7a58bb610 | ||
|
|
227aef3dbf | ||
|
|
34734bf84a | ||
|
|
ff5f6438c4 | ||
|
|
e9b8f525c8 |
@@ -4,7 +4,7 @@ A self-hosted work system-of-record for software projects, built to be driven by
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system, and semantic search with proactive knowledge-injection into Claude's context. A knowledge graph, per-user/group sharing, and a built-in MCP server (`/mcp`) plus a bundled Claude Code plugin so Claude can record and recall your work directly.
|
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system (with an inception step that decides what each project inherits), and semantic search with proactive knowledge-injection into Claude's context. A knowledge graph, per-user/group sharing, and a built-in MCP server (`/mcp`) plus a bundled Claude Code plugin so Claude can record and recall your work directly.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Project inception: the decision record + always-on rulebook exclusions (milestone 297)
|
||||||
|
|
||||||
|
Revision ID: 0085
|
||||||
|
Revises: 0084
|
||||||
|
Create Date: 2026-08-22
|
||||||
|
|
||||||
|
`projects.inception` is the WHY a project inherits what it does — NULL until
|
||||||
|
someone decides, at which point enter_project stops asking. The new
|
||||||
|
association `project_rulebook_exclusions` is the opt-out of a whole always-on
|
||||||
|
rulebook for one project (the sibling of the rule/topic suppressions).
|
||||||
|
|
||||||
|
Backfill: every project that exists when this runs is stamped
|
||||||
|
via="legacy" with its CURRENT standing (no exclusions, its subscriptions,
|
||||||
|
its design_system_id, no seed) — so the ask fires only for projects created
|
||||||
|
after the step shipped, and nothing a running install relies on changes.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0085"
|
||||||
|
down_revision = "0084"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"projects",
|
||||||
|
sa.Column("inception", postgresql.JSONB(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"project_rulebook_exclusions",
|
||||||
|
sa.Column(
|
||||||
|
"project_id", sa.BigInteger(),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True, nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"rulebook_id", sa.BigInteger(),
|
||||||
|
sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True, nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"), nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Legacy stamp: what each existing project inherits today, recorded as a
|
||||||
|
# decision so the inception ask does not fire on a project that has been
|
||||||
|
# running for months.
|
||||||
|
op.execute(sa.text("""
|
||||||
|
UPDATE projects p SET inception = jsonb_build_object(
|
||||||
|
'via', 'legacy',
|
||||||
|
'decided_at', to_jsonb(now()),
|
||||||
|
'decided_by', NULL,
|
||||||
|
'choices', jsonb_build_object(
|
||||||
|
'exclude_always_on_rulebooks', '[]'::jsonb,
|
||||||
|
'subscribe_rulebooks', COALESCE(
|
||||||
|
(SELECT jsonb_agg(s.rulebook_id ORDER BY s.rulebook_id)
|
||||||
|
FROM project_rulebook_subscriptions s
|
||||||
|
WHERE s.project_id = p.id),
|
||||||
|
'[]'::jsonb),
|
||||||
|
'design_system_id', to_jsonb(p.design_system_id),
|
||||||
|
'seed_systems', false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE p.inception IS NULL
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("project_rulebook_exclusions")
|
||||||
|
op.drop_column("projects", "inception")
|
||||||
@@ -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")
|
||||||
@@ -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),
|
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
|
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
|
read tools (`get_*`, `list_*`, `search`, `enter_project`, `retrieval_telemetry`);
|
||||||
is rejected with `403`. A `write`-scoped key may call everything.
|
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)
|
### 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 |
|
| 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 |
|
| Tasks | `create_task`, `update_task`, `add_task_log`, `start_planning` | Actionable work + plans |
|
||||||
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ endpoint at `/mcp`, not these REST routes.
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET / POST | `/api/projects` | List (owned + shared) / create |
|
| GET / POST | `/api/projects` | List (owned + shared) / create |
|
||||||
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`) / update / delete |
|
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`, `inception`) / update / delete |
|
||||||
|
| POST | `/api/projects/:id/inception` | Record what the project inherits `{choices: {exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems}}` (owner-only; `POST /api/projects` accepts the same under `inception`) |
|
||||||
|
| GET | `/api/projects/:id/inception/defaults` | What binds if nobody decides — the inception card's payload |
|
||||||
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
|
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
|
||||||
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
|
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
|
||||||
| GET / PATCH / DELETE | `/api/projects/:id/milestones/:mid` | Read / update / delete |
|
| GET / PATCH / DELETE | `/api/projects/:id/milestones/:mid` | Read / update / delete |
|
||||||
@@ -118,6 +120,7 @@ endpoint at `/mcp`, not these REST routes.
|
|||||||
| POST | `/api/projects/:id/rules` | Create a project-scoped rule |
|
| POST | `/api/projects/:id/rules` | Create a project-scoped rule |
|
||||||
| POST / DELETE | `/api/projects/:id/suppressions/rules/:rid` | Suppress / unsuppress a rule |
|
| POST / DELETE | `/api/projects/:id/suppressions/rules/:rid` | Suppress / unsuppress a rule |
|
||||||
| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic |
|
| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic |
|
||||||
|
| POST / DELETE | `/api/projects/:id/exclusions/rulebooks/:rid` | Exclude / include an always-on rulebook for this project (inception) |
|
||||||
|
|
||||||
## Sharing
|
## Sharing
|
||||||
|
|
||||||
@@ -169,6 +172,8 @@ endpoint at `/mcp`, not these REST routes.
|
|||||||
| GET | `/api/plugin/context` | SessionStart context payload (rules + active-project) |
|
| GET | `/api/plugin/context` | SessionStart context payload (rules + active-project) |
|
||||||
| GET | `/api/plugin/retrieve` | Title-first knowledge-injection candidates |
|
| GET | `/api/plugin/retrieve` | Title-first knowledge-injection candidates |
|
||||||
| GET | `/api/plugin/processes` | Stored Processes for skill-stub sync |
|
| GET | `/api/plugin/processes` | Stored Processes for skill-stub sync |
|
||||||
|
| GET | `/api/plugin/prior-art` | Write-path hint for the plugin hooks (params: `path`, `code`, `repo`, `shapes`, `exclude_ids`, `exclude_sync_ids`, `exclude_derive`); returns `context`, `note_ids`, `sync_note_ids`, `stamped`, `divergence`, `derive`, `derive_keys` |
|
||||||
|
| GET / 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 |
|
| GET / PUT | `/api/plugin/marketplace-url` | Read / set the plugin marketplace URL |
|
||||||
|
|
||||||
## Dashboard, Export, Trash, Users
|
## Dashboard, Export, Trash, Users
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/** Project inception (milestone 297): what a project was decided to inherit. */
|
||||||
|
import { apiGet, apiPost } from "@/api/client";
|
||||||
|
|
||||||
|
export interface InceptionChoices {
|
||||||
|
exclude_always_on_rulebooks: number[];
|
||||||
|
subscribe_rulebooks: number[];
|
||||||
|
design_system_id: number | null;
|
||||||
|
seed_systems: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InceptionRecord {
|
||||||
|
decided_at: string;
|
||||||
|
decided_by: number | null;
|
||||||
|
via: "mcp" | "ui" | "legacy";
|
||||||
|
choices: InceptionChoices;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InceptionDefaults {
|
||||||
|
always_on_rulebooks: { id: number; title: string }[];
|
||||||
|
other_rulebooks: { id: number; title: string }[];
|
||||||
|
excluded_always_on: { id: number; title: string }[];
|
||||||
|
subscribed_rulebooks: { id: number; title: string }[];
|
||||||
|
design_system_id: number | null;
|
||||||
|
design_systems: { id: number; title: string }[];
|
||||||
|
systems: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InceptionDecision {
|
||||||
|
project_id: number;
|
||||||
|
inception: InceptionRecord;
|
||||||
|
effects: { excluded: number[]; subscribed: number[]; design_system_id: number | null; systems_seeded: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyChoices = (): InceptionChoices => ({
|
||||||
|
exclude_always_on_rulebooks: [], subscribe_rulebooks: [], design_system_id: null, seed_systems: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchInceptionDefaults = (projectId: number) =>
|
||||||
|
apiGet<InceptionDefaults>(`/api/projects/${projectId}/inception/defaults`);
|
||||||
|
|
||||||
|
export const decideInception = (projectId: number, choices: InceptionChoices) =>
|
||||||
|
apiPost<InceptionDecision>(`/api/projects/${projectId}/inception`, { choices });
|
||||||
@@ -1,5 +1,24 @@
|
|||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
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 {
|
export interface Rulebook {
|
||||||
id: number;
|
id: number;
|
||||||
owner_user_id: number;
|
owner_user_id: number;
|
||||||
@@ -26,35 +45,53 @@ export interface Rule {
|
|||||||
project_id: number | null;
|
project_id: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
statement: 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;
|
why: string;
|
||||||
how_to_apply: string;
|
how_to_apply: string;
|
||||||
|
/** The note or task that caused this rule, if one was recorded. */
|
||||||
|
arose_from_id: number | null;
|
||||||
order_index: number;
|
order_index: number;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_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 {
|
export interface RuleHeader {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
statement: string;
|
statement: string;
|
||||||
topic_id: number | null;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApplicableRules {
|
export interface ApplicableRules {
|
||||||
rules: {
|
// Both lists are rule_brief's output — the SAME builder, so they are
|
||||||
id: number;
|
// described the same way here rather than as two hand-written shapes that
|
||||||
title: string;
|
// drift from it and from each other (which is what the server side had).
|
||||||
statement: string;
|
rules: (RuleHeader & {
|
||||||
topic_id: number;
|
|
||||||
topic_title: string;
|
topic_title: string;
|
||||||
rulebook_id: number;
|
rulebook_id: number;
|
||||||
rulebook_title: string;
|
rulebook_title: string;
|
||||||
}[];
|
})[];
|
||||||
project_rules: {
|
project_rules: RuleHeader[];
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
statement: string;
|
|
||||||
}[];
|
|
||||||
suppressed_rules: {
|
suppressed_rules: {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -71,6 +108,8 @@ export interface ApplicableRules {
|
|||||||
}[];
|
}[];
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
subscribed_rulebooks: { id: number; title: string }[];
|
subscribed_rulebooks: { id: number; title: string }[];
|
||||||
|
/** Always-on rulebooks this project opted out of at inception (milestone 297). */
|
||||||
|
excluded_always_on: { id: number; title: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rulebooks ───────────────────────────────────────────────────────
|
// ── Rulebooks ───────────────────────────────────────────────────────
|
||||||
@@ -131,14 +170,39 @@ export async function getRule(id: number): Promise<Rule> {
|
|||||||
return apiGet(`/api/rules/${id}`);
|
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. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRule(topicId: number, data: Partial<RuleWrite> & { title: string; statement: string }): Promise<Rule> {
|
||||||
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
|
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);
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteRule(id: number): Promise<void> {
|
export async function deleteRule(id: number): Promise<void> {
|
||||||
return apiDelete(`/api/rules/${id}`);
|
return apiDelete(`/api/rules/${id}`);
|
||||||
}
|
}
|
||||||
@@ -159,7 +223,7 @@ export async function getProjectApplicableRules(projectId: number): Promise<Appl
|
|||||||
|
|
||||||
export async function createProjectRule(
|
export async function createProjectRule(
|
||||||
projectId: number,
|
projectId: number,
|
||||||
data: { statement: string; title?: string; why?: string; how_to_apply?: string },
|
data: Partial<RuleWrite> & { statement: string },
|
||||||
): Promise<Rule> {
|
): Promise<Rule> {
|
||||||
return apiPost(`/api/projects/${projectId}/rules`, data);
|
return apiPost(`/api/projects/${projectId}/rules`, data);
|
||||||
}
|
}
|
||||||
@@ -181,3 +245,14 @@ export async function suppressTopicForProject(projectId: number, topicId: number
|
|||||||
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
|
||||||
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
|
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Always-on exclusions (milestone 297) ────────────────────────────────────
|
||||||
|
|
||||||
|
export async function excludeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
|
||||||
|
await apiPost(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function includeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise<void> {
|
||||||
|
await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||||
|
import type { CanonicalMatch } from "@/api/canonicalSystems";
|
||||||
|
|
||||||
export interface System {
|
export interface System {
|
||||||
id: number;
|
id: number;
|
||||||
project_id: number;
|
project_id: number;
|
||||||
name: string;
|
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;
|
description: string;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
status: "active" | "archived";
|
status: "active" | "archived";
|
||||||
@@ -18,10 +24,23 @@ export async function listSystems(projectId: number): Promise<System[]> {
|
|||||||
return data.systems;
|
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(
|
export async function createSystem(
|
||||||
projectId: number,
|
projectId: number,
|
||||||
data: { name: string; description?: string; color?: string },
|
data: { name: string; description?: string; color?: string; canonical_id?: number },
|
||||||
): Promise<System> {
|
): Promise<CreatedSystem> {
|
||||||
return apiPost(`/api/projects/${projectId}/systems`, data);
|
return apiPost(`/api/projects/${projectId}/systems`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -297,3 +297,57 @@
|
|||||||
background: var(--fs-action-destructive-hover);
|
background: var(--fs-action-destructive-hover);
|
||||||
border-color: 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); }
|
||||||
|
|||||||
@@ -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;
|
padding: 1rem 1.5rem 0.5rem;
|
||||||
border-bottom: 1px solid var(--fs-border-color);
|
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 & inputs ── */
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -78,7 +66,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
.tag-pill {
|
.tag-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -106,95 +94,6 @@
|
|||||||
.tag-check {
|
.tag-check {
|
||||||
font-size: 0.7rem;
|
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 {
|
.assist-instruction {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.65rem;
|
padding: 0.5rem 0.65rem;
|
||||||
@@ -213,33 +112,6 @@
|
|||||||
gap: 0.5rem;
|
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 */
|
/* Active hint shown in the panel while output is inline */
|
||||||
.assist-active-hint {
|
.assist-active-hint {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
@@ -257,16 +129,6 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--fs-error);
|
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);
|
|
||||||
}
|
|
||||||
.diff-view {
|
.diff-view {
|
||||||
border: 1px solid var(--fs-border-color);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--fs-radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
@@ -398,22 +260,9 @@
|
|||||||
|
|
||||||
/* ── Mobile ── */
|
/* ── Mobile ── */
|
||||||
@media (max-width: 768px) {
|
@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 {
|
.editor-header {
|
||||||
padding: 0.75rem 1rem 0.5rem;
|
padding: 0.75rem 1rem 0.5rem;
|
||||||
}
|
}
|
||||||
.editor-main {
|
|
||||||
padding: 0.5rem 1rem 1rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
/* ---------------------------------------------------------------------------
|
||||||
@@ -508,3 +357,36 @@
|
|||||||
opacity: var(--fs-disabled-opacity);
|
opacity: var(--fs-disabled-opacity);
|
||||||
cursor: not-allowed;
|
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,15 @@
|
|||||||
|
/* Shared by the three rules panes (RulebookListPane, RuleListPane,
|
||||||
|
RulebookDetailPane): the pane surface and its heading. 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; }
|
||||||
@@ -272,17 +272,10 @@ button:not(:disabled):active,
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
button,
|
button,
|
||||||
[role="button"],
|
[role="button"] {
|
||||||
.btn-new-conv,
|
|
||||||
.btn-send {
|
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (min-width: 769px) {
|
|
||||||
.hide-desktop {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Neutral hairline scrollbars — chrome is structural, not branded */
|
/* Neutral hairline scrollbars — chrome is structural, not branded */
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
|
|||||||
@@ -212,43 +212,6 @@ router.afterEach(() => {
|
|||||||
box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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) */
|
/* Icon buttons (?, theme, gear) */
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
background: none;
|
background: none;
|
||||||
@@ -263,8 +226,7 @@ router.afterEach(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.btn-icon:hover,
|
.btn-icon:hover {
|
||||||
.btn-icon.active {
|
|
||||||
background: var(--fs-surface-raised);
|
background: var(--fs-surface-raised);
|
||||||
color: var(--fs-text-primary);
|
color: var(--fs-text-primary);
|
||||||
border-color: var(--fs-accent);
|
border-color: var(--fs-accent);
|
||||||
@@ -382,7 +344,6 @@ router.afterEach(() => {
|
|||||||
.nav-center {
|
.nav-center {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.status-indicator,
|
|
||||||
.btn-icon,
|
.btn-icon,
|
||||||
.user-info {
|
.user-info {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* The inception form (milestone 297): "what does this project inherit?"
|
||||||
|
*
|
||||||
|
* Two homes, one component. mode="create" rides the New-project modal's
|
||||||
|
* second step and only emits the choices (the project does not exist yet);
|
||||||
|
* mode="decide" sits on ProjectView for an undecided project, loads that
|
||||||
|
* project's current defaults, and records the decision itself.
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
import { fetchDesignSystems } from "@/api/designSystems";
|
||||||
|
import {
|
||||||
|
decideInception, emptyChoices, fetchInceptionDefaults,
|
||||||
|
type InceptionChoices, type InceptionDecision, type InceptionDefaults,
|
||||||
|
} from "@/api/inception";
|
||||||
|
import { listRulebooks } from "@/api/rulebooks";
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
mode: "create" | "decide";
|
||||||
|
projectId?: number;
|
||||||
|
choices?: InceptionChoices;
|
||||||
|
}>(), { projectId: 0, choices: undefined });
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:choices": [value: InceptionChoices];
|
||||||
|
decided: [decision: InceptionDecision];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const local = ref<InceptionChoices>(props.choices ? { ...props.choices } : emptyChoices());
|
||||||
|
const alwaysOn = ref<{ id: number; title: string }[]>([]);
|
||||||
|
const others = ref<{ id: number; title: string }[]>([]);
|
||||||
|
const designSystems = ref<{ id: number; title: string }[]>([]);
|
||||||
|
const systemsCount = ref(0);
|
||||||
|
const loading = ref(true);
|
||||||
|
const saving = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
function emitChoices() {
|
||||||
|
emit("update:choices", { ...local.value });
|
||||||
|
}
|
||||||
|
watch(local, emitChoices, { deep: true });
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
if (props.mode === "decide" && props.projectId) {
|
||||||
|
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
|
||||||
|
alwaysOn.value = d.always_on_rulebooks;
|
||||||
|
others.value = d.other_rulebooks;
|
||||||
|
designSystems.value = d.design_systems;
|
||||||
|
systemsCount.value = d.systems;
|
||||||
|
// Start from what stands today so "record" without changes is a true inherit-all.
|
||||||
|
local.value = {
|
||||||
|
exclude_always_on_rulebooks: d.excluded_always_on.map((r) => r.id),
|
||||||
|
subscribe_rulebooks: d.subscribed_rulebooks.map((r) => r.id),
|
||||||
|
design_system_id: d.design_system_id,
|
||||||
|
seed_systems: false,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const [rulebooks, ds] = await Promise.all([listRulebooks(), fetchDesignSystems()]);
|
||||||
|
alwaysOn.value = rulebooks.filter((r) => r.always_on).map((r) => ({ id: r.id, title: r.title }));
|
||||||
|
others.value = rulebooks.filter((r) => !r.always_on).map((r) => ({ id: r.id, title: r.title }));
|
||||||
|
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = apiErrorMessage(e, "Could not load what this project could inherit");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inherits(id: number): boolean {
|
||||||
|
return !local.value.exclude_always_on_rulebooks.includes(id);
|
||||||
|
}
|
||||||
|
function toggleInherit(id: number) {
|
||||||
|
const list = local.value.exclude_always_on_rulebooks;
|
||||||
|
local.value.exclude_always_on_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
|
||||||
|
}
|
||||||
|
function subscribed(id: number): boolean {
|
||||||
|
return local.value.subscribe_rulebooks.includes(id);
|
||||||
|
}
|
||||||
|
function toggleSubscribe(id: number) {
|
||||||
|
const list = local.value.subscribe_rulebooks;
|
||||||
|
local.value.subscribe_rulebooks = list.includes(id) ? list.filter((x) => x !== id) : [...list, id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nothingToDecide = computed(
|
||||||
|
() => !alwaysOn.value.length && !others.value.length && !designSystems.value.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function record() {
|
||||||
|
if (!props.projectId) return;
|
||||||
|
saving.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const decision = await decideInception(props.projectId, local.value);
|
||||||
|
emit("decided", decision);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = apiErrorMessage(e, "Could not record the decision");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="inception" aria-labelledby="inception-title">
|
||||||
|
<h3 id="inception-title" class="inception-title">What does this project inherit?</h3>
|
||||||
|
<p class="inception-lede">
|
||||||
|
A project's inheritance is a decision, not a default. Until it is recorded,
|
||||||
|
every always-on rulebook binds, nothing is subscribed, and there is no design
|
||||||
|
system or Systems.
|
||||||
|
</p>
|
||||||
|
<p v-if="loading" class="inception-muted">Loading…</p>
|
||||||
|
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="alwaysOn.length" class="inception-group">
|
||||||
|
<h4>Always-on rulebooks</h4>
|
||||||
|
<p class="inception-muted">Checked = inherits. Uncheck to exclude a rulebook for this project only.</p>
|
||||||
|
<label v-for="rb in alwaysOn" :key="rb.id" class="inception-choice">
|
||||||
|
<input type="checkbox" :checked="inherits(rb.id)" @change="toggleInherit(rb.id)" />
|
||||||
|
<span>{{ rb.title }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="others.length" class="inception-group">
|
||||||
|
<h4>Subscribe to rulebooks</h4>
|
||||||
|
<label v-for="rb in others" :key="rb.id" class="inception-choice">
|
||||||
|
<input type="checkbox" :checked="subscribed(rb.id)" @change="toggleSubscribe(rb.id)" />
|
||||||
|
<span>{{ rb.title }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="inception-group">
|
||||||
|
<h4>Design system</h4>
|
||||||
|
<select v-model="local.design_system_id" class="inception-select" aria-label="Design system">
|
||||||
|
<option :value="null">None</option>
|
||||||
|
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="inception-group">
|
||||||
|
<label class="inception-choice">
|
||||||
|
<input type="checkbox" v-model="local.seed_systems" :disabled="systemsCount > 0" />
|
||||||
|
<span>
|
||||||
|
Seed the standard starter Systems (CI & Release, Auth & Access, Data Model & Storage, …)
|
||||||
|
<em v-if="systemsCount > 0" class="inception-muted"> — this project already has {{ systemsCount }}</em>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-if="nothingToDecide" class="inception-muted">
|
||||||
|
Nothing to inherit yet on this install — recording still settles the question.
|
||||||
|
</p>
|
||||||
|
<div v-if="mode === 'decide'" class="inception-actions">
|
||||||
|
<button class="btn-primary" :disabled="saving" @click="record">
|
||||||
|
{{ saving ? "Recording…" : "Record decision" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.inception {
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-lg);
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.inception-title { margin: 0 0 0.35rem; font-size: 1.05rem; }
|
||||||
|
.inception-lede { margin: 0 0 1rem; color: var(--fs-text-secondary); font-size: 0.9rem; }
|
||||||
|
.inception-muted { color: var(--fs-text-tertiary); font-size: 0.85rem; margin: 0 0 0.35rem; }
|
||||||
|
.inception-group { margin-bottom: 1rem; }
|
||||||
|
.inception-group h4 { margin: 0 0 0.35rem; font-size: 0.9rem; font-weight: 500; }
|
||||||
|
.inception-choice { display: flex; align-items: flex-start; gap: 0.5rem; font-size: 0.9rem; margin: 0.25rem 0; }
|
||||||
|
.inception-choice input { margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||||||
|
.inception-select {
|
||||||
|
padding: 0.45rem 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;
|
||||||
|
}
|
||||||
|
.inception-actions { display: flex; justify-content: flex-end; margin-top: 0.5rem; }
|
||||||
|
</style>
|
||||||
@@ -51,7 +51,7 @@ function onChange(e: Event) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<select
|
<select
|
||||||
class="milestone-select"
|
class="fs-input milestone-select"
|
||||||
:value="modelValue ?? ''"
|
:value="modelValue ?? ''"
|
||||||
:disabled="!projectId || loading"
|
:disabled="!projectId || loading"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
@@ -64,23 +64,10 @@ function onChange(e: Event) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* The input itself is the .fs-input canon (components.css); only the
|
||||||
|
layout remainder lives here. */
|
||||||
.milestone-select {
|
.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;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.milestone-select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--fs-accent);
|
|
||||||
}
|
|
||||||
.milestone-select:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -231,7 +231,6 @@ onMounted(async () => {
|
|||||||
color: var(--fs-text-primary);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.share-tabs {
|
.share-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -307,7 +306,6 @@ onMounted(async () => {
|
|||||||
.user-result-item:hover { background: var(--fs-surface-raised); }
|
.user-result-item:hover { background: var(--fs-surface-raised); }
|
||||||
|
|
||||||
.user-result-name { font-weight: 600; font-size: 0.88rem; }
|
.user-result-name { font-weight: 600; font-size: 0.88rem; }
|
||||||
.user-result-email { color: var(--fs-text-tertiary); font-size: 0.8rem; }
|
|
||||||
|
|
||||||
.perm-select {
|
.perm-select {
|
||||||
padding: 0.45rem 0.5rem;
|
padding: 0.45rem 0.5rem;
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from "vue";
|
import { ref, computed, onMounted, watch } from "vue";
|
||||||
import { useSystemsStore } from "@/stores/systems";
|
import { useSystemsStore } from "@/stores/systems";
|
||||||
|
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { getProjectIssues } from "@/api/systems";
|
import { getProjectIssues } from "@/api/systems";
|
||||||
import type { System, TaskLike } 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";
|
import { Pencil, Trash2, Archive, ArchiveRestore } from "lucide-vue-next";
|
||||||
|
|
||||||
const props = defineProps<{ projectId: number }>();
|
const props = defineProps<{ projectId: number }>();
|
||||||
|
|
||||||
const store = useSystemsStore();
|
const store = useSystemsStore();
|
||||||
|
const canon = useCanonicalSystemsStore();
|
||||||
const toast = useToastStore();
|
const toast = useToastStore();
|
||||||
|
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
@@ -19,14 +23,26 @@ const issues = ref<TaskLike[]>([]);
|
|||||||
const showCreate = ref(false);
|
const showCreate = ref(false);
|
||||||
const newName = ref("");
|
const newName = ref("");
|
||||||
const newDescription = 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);
|
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
|
// Edit state
|
||||||
const editingId = ref<number | null>(null);
|
const editingId = ref<number | null>(null);
|
||||||
const editName = ref("");
|
const editName = ref("");
|
||||||
const editDescription = ref("");
|
const editDescription = ref("");
|
||||||
|
const editCanonicalId = ref<number | null>(null);
|
||||||
const savingEdit = ref(false);
|
const savingEdit = ref(false);
|
||||||
|
|
||||||
|
// Mapping review
|
||||||
|
const showReview = ref(false);
|
||||||
|
const reviewBusy = ref<number | null>(null);
|
||||||
|
|
||||||
// Delete confirmation
|
// Delete confirmation
|
||||||
const deletingSystem = ref<System | null>(null);
|
const deletingSystem = ref<System | null>(null);
|
||||||
|
|
||||||
@@ -37,6 +53,12 @@ const visibleSystems = computed(() =>
|
|||||||
showArchived.value ? systems.value : activeSystems.value,
|
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() {
|
async function load() {
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
@@ -49,6 +71,14 @@ async function load() {
|
|||||||
} catch {
|
} catch {
|
||||||
issues.value = [];
|
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);
|
onMounted(load);
|
||||||
@@ -58,12 +88,14 @@ function openCreate() {
|
|||||||
showCreate.value = true;
|
showCreate.value = true;
|
||||||
newName.value = "";
|
newName.value = "";
|
||||||
newDescription.value = "";
|
newDescription.value = "";
|
||||||
|
newCanonicalId.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelCreate() {
|
function cancelCreate() {
|
||||||
showCreate.value = false;
|
showCreate.value = false;
|
||||||
newName.value = "";
|
newName.value = "";
|
||||||
newDescription.value = "";
|
newDescription.value = "";
|
||||||
|
newCanonicalId.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitCreate() {
|
async function submitCreate() {
|
||||||
@@ -71,23 +103,60 @@ async function submitCreate() {
|
|||||||
if (!name || creating.value) return;
|
if (!name || creating.value) return;
|
||||||
creating.value = true;
|
creating.value = true;
|
||||||
try {
|
try {
|
||||||
await store.createSystem(props.projectId, {
|
const created = await store.createSystem(props.projectId, {
|
||||||
name,
|
name,
|
||||||
description: newDescription.value.trim() || undefined,
|
description: newDescription.value.trim() || undefined,
|
||||||
|
canonical_id: newCanonicalId.value ?? undefined,
|
||||||
});
|
});
|
||||||
cancelCreate();
|
cancelCreate();
|
||||||
toast.show("System created");
|
if (created.canonical_suggestion) {
|
||||||
} catch {
|
// An overlap: shown as an offer beside the new System, never applied.
|
||||||
toast.show("Failed to create system", "error");
|
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 {
|
} finally {
|
||||||
creating.value = false;
|
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) {
|
function startEdit(system: System) {
|
||||||
editingId.value = system.id;
|
editingId.value = system.id;
|
||||||
editName.value = system.name;
|
editName.value = system.name;
|
||||||
editDescription.value = system.description;
|
editDescription.value = system.description;
|
||||||
|
editCanonicalId.value = system.canonical_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
@@ -103,6 +172,12 @@ async function submitEdit(system: System) {
|
|||||||
name,
|
name,
|
||||||
description: editDescription.value.trim(),
|
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;
|
editingId.value = null;
|
||||||
toast.show("System updated");
|
toast.show("System updated");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -161,6 +236,65 @@ async function confirmDelete() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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 -->
|
<!-- Toolbar -->
|
||||||
<div class="systems-toolbar">
|
<div class="systems-toolbar">
|
||||||
<button v-if="!showCreate" class="btn-ghost btn-inline btn-add-system" @click="openCreate">
|
<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">
|
<form v-if="showCreate" class="system-form" @submit.prevent="submitCreate">
|
||||||
<input
|
<input
|
||||||
v-model="newName"
|
v-model="newName"
|
||||||
class="system-input"
|
class="fs-input system-input"
|
||||||
placeholder="System name"
|
placeholder="System name"
|
||||||
aria-label="System name"
|
aria-label="System name"
|
||||||
autofocus
|
autofocus
|
||||||
@@ -184,11 +318,25 @@ async function confirmDelete() {
|
|||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newDescription"
|
v-model="newDescription"
|
||||||
class="system-textarea"
|
class="fs-input system-textarea"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="What is this subsystem responsible for? (optional)"
|
placeholder="What is this subsystem responsible for? (optional)"
|
||||||
aria-label="System description"
|
aria-label="System description"
|
||||||
></textarea>
|
></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">
|
<div class="system-form-actions">
|
||||||
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
|
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
|
||||||
{{ creating ? "Creating…" : "Create" }}
|
{{ creating ? "Creating…" : "Create" }}
|
||||||
@@ -227,7 +375,7 @@ async function confirmDelete() {
|
|||||||
<form class="system-form system-form--inline" @submit.prevent="submitEdit(system)">
|
<form class="system-form system-form--inline" @submit.prevent="submitEdit(system)">
|
||||||
<input
|
<input
|
||||||
v-model="editName"
|
v-model="editName"
|
||||||
class="system-input"
|
class="fs-input system-input"
|
||||||
placeholder="System name"
|
placeholder="System name"
|
||||||
aria-label="System name"
|
aria-label="System name"
|
||||||
autofocus
|
autofocus
|
||||||
@@ -235,11 +383,20 @@ async function confirmDelete() {
|
|||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="editDescription"
|
v-model="editDescription"
|
||||||
class="system-textarea"
|
class="fs-input system-textarea"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="Description (optional)"
|
placeholder="Description (optional)"
|
||||||
aria-label="System description"
|
aria-label="System description"
|
||||||
></textarea>
|
></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">
|
<div class="system-form-actions">
|
||||||
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
|
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
|
||||||
{{ savingEdit ? "Saving…" : "Save" }}
|
{{ savingEdit ? "Saving…" : "Save" }}
|
||||||
@@ -264,6 +421,13 @@ async function confirmDelete() {
|
|||||||
:title="`${system.open_issue_count} open issue(s)`"
|
:title="`${system.open_issue_count} open issue(s)`"
|
||||||
>{{ system.open_issue_count }} open</span>
|
>{{ system.open_issue_count }} open</span>
|
||||||
<span v-if="system.status === 'archived'" class="archived-badge">Archived</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>
|
</div>
|
||||||
<p v-if="system.description" class="system-description">{{ system.description }}</p>
|
<p v-if="system.description" class="system-description">{{ system.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,6 +499,91 @@ async function confirmDelete() {
|
|||||||
.issue-systems { display: flex; gap: 0.25rem; flex-shrink: 0; flex-wrap: wrap; }
|
.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; }
|
.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); }
|
||||||
|
.area-basis--overlap { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); }
|
||||||
|
|
||||||
|
.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 ──────────────────────────────────────────────────── */
|
/* ── Toolbar ──────────────────────────────────────────────────── */
|
||||||
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
||||||
.btn-add-system {
|
.btn-add-system {
|
||||||
@@ -372,18 +621,9 @@ async function confirmDelete() {
|
|||||||
border-radius: var(--fs-radius-lg);
|
border-radius: var(--fs-radius-lg);
|
||||||
}
|
}
|
||||||
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
|
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
|
||||||
.system-input, .system-textarea {
|
/* The input itself is the .fs-input canon (components.css); only the
|
||||||
padding: 0.4rem 0.6rem;
|
layout remainder lives here. */
|
||||||
border: 1px solid var(--fs-border-color);
|
.system-input, .system-textarea { box-sizing: border-box; width: 100%; }
|
||||||
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); }
|
|
||||||
.system-textarea { resize: vertical; }
|
.system-textarea { resize: vertical; }
|
||||||
|
|
||||||
.system-form-actions { display: flex; gap: 0.4rem; }
|
.system-form-actions { display: flex; gap: 0.4rem; }
|
||||||
@@ -493,10 +733,10 @@ async function confirmDelete() {
|
|||||||
border: 1px dashed var(--fs-border-color);
|
border: 1px dashed var(--fs-border-color);
|
||||||
border-radius: var(--fs-radius-lg);
|
border-radius: var(--fs-radius-lg);
|
||||||
}
|
}
|
||||||
.empty-title { margin: 0; font-weight: 500; color: var(--fs-text-primary); }
|
/* remainders over the shared recipes (components.css, m302) */
|
||||||
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--fs-text-tertiary); max-width: 32ch; }
|
.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 ─────────────────────────────────────────────────── */
|
/* ── Skeleton ─────────────────────────────────────────────────── */
|
||||||
@keyframes skel-shine { to { background-position: 200% center; } }
|
@keyframes skel-shine { to { background-position: 200% center; } }
|
||||||
|
|||||||
@@ -471,7 +471,6 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.rail-search-input {
|
.rail-search-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -575,8 +574,6 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-row:hover .btn-delete { opacity: 1; }
|
|
||||||
|
|
||||||
/* Editor UI */
|
/* Editor UI */
|
||||||
.panel-header {
|
.panel-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -624,8 +621,6 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
}
|
}
|
||||||
.tag-row > :first-child { flex: 1; min-width: 0; }
|
.tag-row > :first-child { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
.btn-suggest-tags { flex-shrink: 0; align-self: center; }
|
|
||||||
|
|
||||||
.tag-suggestions {
|
.tag-suggestions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -653,7 +648,6 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
color: var(--fs-accent);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.link-suggest-strip {
|
.link-suggest-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -2,10 +2,18 @@
|
|||||||
import { ref, onMounted, watch } from "vue";
|
import { ref, onMounted, watch } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import {
|
import {
|
||||||
getProjectApplicableRules, subscribeProject, unsubscribeProject,
|
getProjectApplicableRules,
|
||||||
listRulebooks, getRule, createProjectRule, deleteRule,
|
subscribeProject,
|
||||||
suppressRuleForProject, unsuppressRuleForProject,
|
unsubscribeProject,
|
||||||
suppressTopicForProject, unsuppressTopicForProject,
|
listRulebooks,
|
||||||
|
getRule,
|
||||||
|
createProjectRule,
|
||||||
|
deleteRule,
|
||||||
|
suppressRuleForProject,
|
||||||
|
unsuppressRuleForProject,
|
||||||
|
suppressTopicForProject,
|
||||||
|
unsuppressTopicForProject,
|
||||||
|
includeAlwaysOnRulebook,
|
||||||
} from "@/api/rulebooks";
|
} from "@/api/rulebooks";
|
||||||
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
import type { ApplicableRules, Rulebook } from "@/api/rulebooks";
|
||||||
|
|
||||||
@@ -19,7 +27,10 @@ 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 }>>({});
|
||||||
|
|
||||||
const showProjectRuleForm = ref(false);
|
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() {
|
async function load() {
|
||||||
applicable.value = await getProjectApplicableRules(props.projectId);
|
applicable.value = await getProjectApplicableRules(props.projectId);
|
||||||
@@ -35,6 +46,11 @@ async function subscribe(rulebookId: number) {
|
|||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function includeBack(rulebookId: number) {
|
||||||
|
await includeAlwaysOnRulebook(props.projectId, rulebookId);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
async function unsubscribe(rulebookId: number) {
|
async function unsubscribe(rulebookId: number) {
|
||||||
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
|
if (!confirm("Unsubscribe from this rulebook for this project?")) return;
|
||||||
await unsubscribeProject(props.projectId, rulebookId);
|
await unsubscribeProject(props.projectId, rulebookId);
|
||||||
@@ -77,14 +93,20 @@ interface RulebookGroup {
|
|||||||
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
|
function groupByRulebookAndTopic(rules: ApplicableRules["rules"]): RulebookGroup[] {
|
||||||
const byRulebook = new Map<number, RulebookGroup>();
|
const byRulebook = new Map<number, RulebookGroup>();
|
||||||
for (const r of rules) {
|
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);
|
let rb = byRulebook.get(r.rulebook_id);
|
||||||
if (!rb) {
|
if (!rb) {
|
||||||
rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] };
|
rb = { rulebook_id: r.rulebook_id, rulebook_title: r.rulebook_title, topics: [] };
|
||||||
byRulebook.set(r.rulebook_id, rb);
|
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) {
|
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);
|
rb.topics.push(topic);
|
||||||
}
|
}
|
||||||
topic.rules.push(r);
|
topic.rules.push(r);
|
||||||
@@ -100,8 +122,13 @@ async function submitProjectRule() {
|
|||||||
title: newProjectRule.value.title.trim() || undefined,
|
title: newProjectRule.value.title.trim() || undefined,
|
||||||
why: newProjectRule.value.why.trim() || undefined,
|
why: newProjectRule.value.why.trim() || undefined,
|
||||||
how_to_apply: newProjectRule.value.how_to_apply.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;
|
showProjectRuleForm.value = false;
|
||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
@@ -172,6 +199,17 @@ watch(() => props.projectId, load);
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="applicable.excluded_always_on?.length" class="excluded">
|
||||||
|
<h3>Excluded always-on rulebooks</h3>
|
||||||
|
<p class="excluded-note">Opted out at inception — these do not bind this project.</p>
|
||||||
|
<div class="chips">
|
||||||
|
<span v-for="rb in applicable.excluded_always_on" :key="rb.id" class="chip chip-excluded">
|
||||||
|
<a @click="openInRulesView(rb.id)">{{ rb.title }}</a>
|
||||||
|
<button class="chip-remove" @click="includeBack(rb.id)" aria-label="Include again" title="Include again">↩</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="project-rules">
|
<section class="project-rules">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h3>Project rules</h3>
|
<h3>Project rules</h3>
|
||||||
@@ -195,6 +233,24 @@ watch(() => props.projectId, load);
|
|||||||
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
|
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
|
||||||
rows="2"
|
rows="2"
|
||||||
></textarea>
|
></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
|
<textarea
|
||||||
v-model="newProjectRule.why"
|
v-model="newProjectRule.why"
|
||||||
placeholder="Why (optional) — the rationale"
|
placeholder="Why (optional) — the rationale"
|
||||||
@@ -321,6 +377,14 @@ watch(() => props.projectId, load);
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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; }
|
||||||
.rules-tab { padding: 1rem; }
|
.rules-tab { padding: 1rem; }
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
|||||||
@@ -1,16 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted } from "vue";
|
import { computed, ref, watch, onMounted } from "vue";
|
||||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
|
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||||
|
import type { RuleTier } from "@/api/rulebooks";
|
||||||
|
|
||||||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||||||
const emit = defineEmits<{ close: [] }>();
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
const store = useRulebooksStore();
|
const store = useRulebooksStore();
|
||||||
|
const canon = useCanonicalSystemsStore();
|
||||||
const title = ref("");
|
const title = ref("");
|
||||||
const statement = ref("");
|
const statement = ref("");
|
||||||
|
const whenToApply = ref("");
|
||||||
|
const tier = ref<RuleTier>("always_on");
|
||||||
|
const systemIds = ref<number[]>([]);
|
||||||
const why = ref("");
|
const why = ref("");
|
||||||
const howToApply = ref("");
|
const howToApply = 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);
|
const isCreating = ref(props.ruleId === null);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -20,15 +45,22 @@ async function load() {
|
|||||||
if (r) {
|
if (r) {
|
||||||
title.value = r.title;
|
title.value = r.title;
|
||||||
statement.value = r.statement;
|
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 || "";
|
why.value = r.why || "";
|
||||||
howToApply.value = r.how_to_apply || "";
|
howToApply.value = r.how_to_apply || "";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
title.value = "";
|
title.value = "";
|
||||||
statement.value = "";
|
statement.value = "";
|
||||||
|
whenToApply.value = "";
|
||||||
|
tier.value = "always_on";
|
||||||
|
systemIds.value = [];
|
||||||
why.value = "";
|
why.value = "";
|
||||||
howToApply.value = "";
|
howToApply.value = "";
|
||||||
}
|
}
|
||||||
|
await canon.fetchCatalog();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -36,16 +68,21 @@ async function save() {
|
|||||||
emit("close");
|
emit("close");
|
||||||
return;
|
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,
|
||||||
|
};
|
||||||
if (isCreating.value && props.topicId !== null) {
|
if (isCreating.value && props.topicId !== null) {
|
||||||
await store.createRule(props.topicId, {
|
await store.createRule(props.topicId, fields);
|
||||||
title: title.value, statement: statement.value,
|
|
||||||
why: why.value, how_to_apply: howToApply.value,
|
|
||||||
});
|
|
||||||
} else if (props.ruleId !== null) {
|
} else if (props.ruleId !== null) {
|
||||||
await store.updateRule(props.ruleId, {
|
await store.updateRule(props.ruleId, fields);
|
||||||
title: title.value, statement: statement.value,
|
|
||||||
why: why.value, how_to_apply: howToApply.value,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
emit("close");
|
emit("close");
|
||||||
}
|
}
|
||||||
@@ -77,6 +114,69 @@ watch(() => props.ruleId, load);
|
|||||||
Statement <span class="required">*</span>
|
Statement <span class="required">*</span>
|
||||||
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
||||||
</label>
|
</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>
|
||||||
|
|
||||||
|
<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>
|
<label>
|
||||||
Why
|
Why
|
||||||
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
|
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
|
||||||
@@ -118,6 +218,19 @@ input, textarea {
|
|||||||
padding: 0.5rem; font: inherit;
|
padding: 0.5rem; font: inherit;
|
||||||
font-family: 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); }
|
||||||
|
|
||||||
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
||||||
.trash:hover, .close:hover { opacity: 1; }
|
.trash:hover, .close:hover { opacity: 1; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,17 +13,25 @@ const emit = defineEmits<{
|
|||||||
<header><h2>Rules</h2></header>
|
<header><h2>Rules</h2></header>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
<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="tier-chip" title="Arrives when its trigger fires, rather than in every session">conditional</span>
|
||||||
|
</div>
|
||||||
<div class="statement">{{ r.statement }}</div>
|
<div class="statement">{{ r.statement }}</div>
|
||||||
|
<div v-if="r.when_to_apply || r.updated_at" class="meta">
|
||||||
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<button class="new-rule" @click="emit('create-rule', topicId)">+ New rule</button>
|
<button class="new-rule" @click="emit('create-rule', topicId)">+ New rule</button>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style src="@/assets/rules-shared.css" />
|
||||||
<style scoped>
|
<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; }
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
li {
|
li {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
@@ -36,5 +44,19 @@ li {
|
|||||||
li:hover { background: var(--fs-surface-hover); }
|
li:hover { background: var(--fs-surface-hover); }
|
||||||
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
||||||
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
.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; }
|
||||||
|
.tier-chip {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
font-family: var(--fs-font-body);
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
border-radius: var(--fs-radius-pill);
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
.new-rule { cursor: pointer; }
|
.new-rule { cursor: pointer; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -121,10 +121,9 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
|||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style src="@/assets/rules-shared.css" />
|
||||||
<style scoped>
|
<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 { 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 {
|
.always-on-toggle {
|
||||||
display: flex; align-items: center; gap: 0.4rem;
|
display: flex; align-items: center; gap: 0.4rem;
|
||||||
font-size: 0.85rem; opacity: 0.85; cursor: pointer;
|
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;
|
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
.form-buttons { display: flex; gap: 0.5rem; }
|
|
||||||
.subscriptions {
|
.subscriptions {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
border-top: 1px solid var(--fs-border-color);
|
border-top: 1px solid var(--fs-border-color);
|
||||||
|
|||||||
@@ -47,9 +47,8 @@ async function submitNew() {
|
|||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style src="@/assets/rules-shared.css" />
|
||||||
<style scoped>
|
<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; }
|
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 { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
|
||||||
li.active { background: var(--fs-accent-soft); }
|
li.active { background: var(--fs-accent-soft); }
|
||||||
@@ -71,6 +70,5 @@ li:hover { background: var(--fs-surface-hover); }
|
|||||||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
.form-buttons { display: flex; gap: 0.5rem; }
|
|
||||||
button { cursor: pointer; }
|
button { cursor: pointer; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -35,9 +35,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
async function fetchRules(topicId: number) {
|
async function fetchRules(topicId: number) {
|
||||||
try {
|
try {
|
||||||
const rules = await api.listRules({ topic_id: topicId });
|
const rules = await api.listRules({ topic_id: topicId });
|
||||||
rulesByTopic.value[topicId] = rules.map((r) => ({
|
rulesByTopic.value[topicId] = rules.map(toHeader);
|
||||||
id: r.id, title: r.title, statement: r.statement, topic_id: r.topic_id,
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
useToastStore().show("Failed to load rules", "error");
|
useToastStore().show("Failed to load rules", "error");
|
||||||
throw e;
|
throw e;
|
||||||
@@ -98,24 +96,58 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
delete rulesByTopic.value[id];
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRule(topicId: number, data: Partial<api.RuleWrite> & { title: string; statement: string }) {
|
||||||
const rule = await api.createRule(topicId, data);
|
const rule = await api.createRule(topicId, data);
|
||||||
if (!rulesByTopic.value[topicId]) rulesByTopic.value[topicId] = [];
|
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;
|
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);
|
const rule = await api.updateRule(id, data);
|
||||||
if (currentRule.value?.id === id) currentRule.value = rule;
|
if (currentRule.value?.id === id) currentRule.value = rule;
|
||||||
for (const tid of Object.keys(rulesByTopic.value)) {
|
for (const tid of Object.keys(rulesByTopic.value)) {
|
||||||
const list = rulesByTopic.value[Number(tid)];
|
const list = rulesByTopic.value[Number(tid)];
|
||||||
const idx = list.findIndex((r) => r.id === id);
|
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;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteRule(id: number) {
|
async function deleteRule(id: number) {
|
||||||
await api.deleteRule(id);
|
await api.deleteRule(id);
|
||||||
if (currentRule.value?.id === id) currentRule.value = null;
|
if (currentRule.value?.id === id) currentRule.value = null;
|
||||||
@@ -129,6 +161,6 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
|||||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||||
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
createRulebook, updateRulebook, toggleAlwaysOn, deleteRulebook,
|
||||||
createTopic, updateTopic, deleteTopic,
|
createTopic, updateTopic, deleteTopic,
|
||||||
createRule, updateRule, deleteRule,
|
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const useSystemsStore = defineStore("systems", () => {
|
|||||||
|
|
||||||
async function createSystem(
|
async function createSystem(
|
||||||
projectId: number,
|
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);
|
const system = await api.createSystem(projectId, data);
|
||||||
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
|
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
|
||||||
|
|||||||
@@ -557,14 +557,14 @@ function isSelfContainedColour(value: string): boolean {
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="first-title">Title</label>
|
<label class="field-label" for="first-title">Title</label>
|
||||||
<input
|
<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"
|
placeholder="Your house style" @keyup.enter="submitCreate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="first-desc">Description</label>
|
<label class="field-label" for="first-desc">Description</label>
|
||||||
<input
|
<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"
|
placeholder="What it covers"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -612,20 +612,20 @@ function isSelfContainedColour(value: string): boolean {
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="new-title">Title</label>
|
<label class="field-label" for="new-title">Title</label>
|
||||||
<input
|
<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"
|
placeholder="A house style, or one app in it" @keyup.enter="submitCreate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="new-desc">Description</label>
|
<label class="field-label" for="new-desc">Description</label>
|
||||||
<input
|
<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"
|
placeholder="What it covers"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="new-parent">Inherits from</label>
|
<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 :value="null">Nothing — this is a family system</option>
|
||||||
<option v-for="s in systems" :key="s.id" :value="s.id">{{ s.title }}</option>
|
<option v-for="s in systems" :key="s.id" :value="s.id">{{ s.title }}</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -659,16 +659,16 @@ function isSelfContainedColour(value: string): boolean {
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="edit-title">Title</label>
|
<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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="edit-desc">Description</label>
|
<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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="edit-guidance">Guidance</label>
|
<label class="field-label" for="edit-guidance">Guidance</label>
|
||||||
<textarea
|
<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…"
|
placeholder="Aesthetic, voice and tone, what's deliberately out of scope…"
|
||||||
></textarea>
|
></textarea>
|
||||||
<p class="field-hint">
|
<p class="field-hint">
|
||||||
@@ -678,7 +678,7 @@ function isSelfContainedColour(value: string): boolean {
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="edit-parent">Inherits from</label>
|
<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 :value="null">Nothing — this is a family system</option>
|
||||||
<option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.title }}</option>
|
<option v-for="s in parentOptions" :key="s.id" :value="s.id">{{ s.title }}</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -887,19 +887,19 @@ function isSelfContainedColour(value: string): boolean {
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="token-name">Name</label>
|
<label class="field-label" for="token-name">Name</label>
|
||||||
<input
|
<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"
|
placeholder="--surface-page"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="token-group">Group</label>
|
<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>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="token-purpose">Purpose</label>
|
<label class="field-label" for="token-purpose">Purpose</label>
|
||||||
<input
|
<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"
|
placeholder="page background, deepest surface"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -908,7 +908,7 @@ function isSelfContainedColour(value: string): boolean {
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="token-rationale">Why this value</label>
|
<label class="field-label" for="token-rationale">Why this value</label>
|
||||||
<input
|
<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"
|
placeholder="Matches the primary action colour, deliberately"
|
||||||
/>
|
/>
|
||||||
<p class="field-hint">
|
<p class="field-hint">
|
||||||
@@ -920,7 +920,7 @@ function isSelfContainedColour(value: string): boolean {
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="token-supersedes">Use instead of</label>
|
<label class="field-label" for="token-supersedes">Use instead of</label>
|
||||||
<input
|
<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"
|
placeholder="#fff, #ffffff"
|
||||||
/>
|
/>
|
||||||
<p class="field-hint">
|
<p class="field-hint">
|
||||||
@@ -941,8 +941,8 @@ function isSelfContainedColour(value: string): boolean {
|
|||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
<div v-for="(row, i) in tokenModes" :key="i" class="mode-row">
|
<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.mode" class="fs-input input mono mode-key" type="text" placeholder="base" />
|
||||||
<input v-model="row.value" class="input mono" type="text" placeholder="#14171a" />
|
<input v-model="row.value" class="fs-input input mono" type="text" placeholder="#14171a" />
|
||||||
<span
|
<span
|
||||||
v-if="isSelfContainedColour(row.value)" class="swatch"
|
v-if="isSelfContainedColour(row.value)" class="swatch"
|
||||||
:style="{ background: row.value }" aria-hidden="true"
|
:style="{ background: row.value }" aria-hidden="true"
|
||||||
@@ -1287,20 +1287,13 @@ function isSelfContainedColour(value: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field-hint {
|
.field-hint {
|
||||||
margin: 0.3rem 0 0;
|
line-height: 1.5; /* remainder over the shared recipe */
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* remainder over .fs-input (components.css, canon #2336; m302) */
|
||||||
.input {
|
.input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.45rem 0.6rem;
|
box-sizing: border-box;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -574,6 +574,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style src="@/assets/dup-report.css" />
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* ── Root layout ─────────────────────────────────────────── */
|
/* ── Root layout ─────────────────────────────────────────── */
|
||||||
.knowledge-root {
|
.knowledge-root {
|
||||||
@@ -606,14 +607,6 @@ onUnmounted(() => {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.78rem;
|
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 ─────────────────────────────────────────── */
|
/* ── Main layout ─────────────────────────────────────────── */
|
||||||
.knowledge-layout {
|
.knowledge-layout {
|
||||||
@@ -1041,57 +1034,6 @@ onUnmounted(() => {
|
|||||||
height: 100%;
|
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". */
|
/* A set someone already ruled on — quiet, not celebratory: it means "skip". */
|
||||||
.dup-claimed {
|
.dup-claimed {
|
||||||
font-size: 0.72rem;
|
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>
|
|
||||||
@@ -626,16 +626,6 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
gap: 0.75rem;
|
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 {
|
.editor-tabs {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
background: var(--fs-surface-page);
|
background: var(--fs-surface-page);
|
||||||
@@ -673,28 +663,11 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.body-editor-wrap {
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stream-label {
|
.stream-label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--fs-text-tertiary);
|
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 */
|
/* Right sidebar */
|
||||||
.note-sidebar {
|
.note-sidebar {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
@@ -721,14 +694,6 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
border-color: var(--fs-accent);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tag suggest row inside sidebar */
|
|
||||||
.tag-suggest-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.3rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Link Suggestions */
|
/* Link Suggestions */
|
||||||
.link-suggest-field { gap: 0.4rem; }
|
.link-suggest-field { gap: 0.4rem; }
|
||||||
|
|
||||||
@@ -798,14 +763,6 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
gap: 0.5rem;
|
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 ─────────────────────────────────────── */
|
/* ── Process editor ─────────────────────────────────────── */
|
||||||
.ef-label {
|
.ef-label {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { apiGet, apiPost } from "@/api/client";
|
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
|
||||||
|
import { emptyChoices, type InceptionChoices } from "@/api/inception";
|
||||||
|
import InceptionCard from "@/components/InceptionCard.vue";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { milestoneColor } from "@/utils/palette";
|
import { milestoneColor } from "@/utils/palette";
|
||||||
|
|
||||||
@@ -47,6 +49,9 @@ const newTitle = ref("");
|
|||||||
const newDescription = ref("");
|
const newDescription = ref("");
|
||||||
const newGoal = ref("");
|
const newGoal = ref("");
|
||||||
const creating = ref(false);
|
const creating = ref(false);
|
||||||
|
// Step 2 of the modal (milestone 297): what the new project inherits.
|
||||||
|
const modalStep = ref<1 | 2>(1);
|
||||||
|
const newInception = ref<InceptionChoices>(emptyChoices());
|
||||||
|
|
||||||
const filteredProjects = computed(() => {
|
const filteredProjects = computed(() => {
|
||||||
if (activeTab.value === "all") return projects.value;
|
if (activeTab.value === "all") return projects.value;
|
||||||
@@ -73,6 +78,8 @@ function openNewProjectModal() {
|
|||||||
newTitle.value = "";
|
newTitle.value = "";
|
||||||
newDescription.value = "";
|
newDescription.value = "";
|
||||||
newGoal.value = "";
|
newGoal.value = "";
|
||||||
|
modalStep.value = 1;
|
||||||
|
newInception.value = emptyChoices();
|
||||||
showNewProjectModal.value = true;
|
showNewProjectModal.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,13 +95,15 @@ async function createProject() {
|
|||||||
title: newTitle.value.trim(),
|
title: newTitle.value.trim(),
|
||||||
description: newDescription.value.trim() || undefined,
|
description: newDescription.value.trim() || undefined,
|
||||||
goal: newGoal.value.trim() || undefined,
|
goal: newGoal.value.trim() || undefined,
|
||||||
|
// The decision rides the create: a project made here is never undecided.
|
||||||
|
inception: newInception.value,
|
||||||
});
|
});
|
||||||
projects.value.unshift(project);
|
projects.value.unshift(project);
|
||||||
showNewProjectModal.value = false;
|
showNewProjectModal.value = false;
|
||||||
toast.show("Project created");
|
toast.show("Project created");
|
||||||
router.push(`/projects/${project.id}`);
|
router.push(`/projects/${project.id}`);
|
||||||
} catch {
|
} catch (e: unknown) {
|
||||||
toast.show("Failed to create project", "error");
|
toast.show(apiErrorMessage(e, "Failed to create project"), "error");
|
||||||
} finally {
|
} finally {
|
||||||
creating.value = false;
|
creating.value = false;
|
||||||
}
|
}
|
||||||
@@ -162,7 +171,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="projects-list">
|
<main class="page-container">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Projects</h1>
|
<h1>Projects</h1>
|
||||||
<button class="btn-primary" @click="openNewProjectModal">+ New Project</button>
|
<button class="btn-primary" @click="openNewProjectModal">+ New Project</button>
|
||||||
@@ -266,8 +275,9 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
<teleport to="body">
|
<teleport to="body">
|
||||||
<div v-if="showNewProjectModal" class="modal-overlay" @click.self="closeModal">
|
<div v-if="showNewProjectModal" class="modal-overlay" @click.self="closeModal">
|
||||||
<div class="modal-card">
|
<div class="modal-card">
|
||||||
<h3 class="modal-title">New Project</h3>
|
<h3 class="modal-title">{{ modalStep === 1 ? "New Project" : "New Project — what it inherits" }}</h3>
|
||||||
<div class="modal-field">
|
<InceptionCard v-if="modalStep === 2" mode="create" v-model:choices="newInception" />
|
||||||
|
<div v-if="modalStep === 1" class="modal-field">
|
||||||
<label>Title <span class="required">*</span></label>
|
<label>Title <span class="required">*</span></label>
|
||||||
<input
|
<input
|
||||||
v-model="newTitle"
|
v-model="newTitle"
|
||||||
@@ -275,11 +285,11 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
class="modal-input"
|
class="modal-input"
|
||||||
placeholder="Project title"
|
placeholder="Project title"
|
||||||
autofocus
|
autofocus
|
||||||
@keydown.enter="createProject"
|
@keydown.enter="modalStep = 2"
|
||||||
@keydown.escape="closeModal"
|
@keydown.escape="closeModal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-field">
|
<div v-if="modalStep === 1" class="modal-field">
|
||||||
<label>Goal</label>
|
<label>Goal</label>
|
||||||
<input
|
<input
|
||||||
v-model="newGoal"
|
v-model="newGoal"
|
||||||
@@ -289,7 +299,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
@keydown.escape="closeModal"
|
@keydown.escape="closeModal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-field">
|
<div v-if="modalStep === 1" class="modal-field">
|
||||||
<label>Description</label>
|
<label>Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="newDescription"
|
v-model="newDescription"
|
||||||
@@ -301,7 +311,17 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="modal-btn" @click="closeModal">Cancel</button>
|
<button class="modal-btn" @click="closeModal">Cancel</button>
|
||||||
|
<button v-if="modalStep === 2" class="modal-btn" @click="modalStep = 1">Back</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="modalStep === 1"
|
||||||
|
class="modal-btn modal-btn-primary"
|
||||||
|
@click="modalStep = 2"
|
||||||
|
:disabled="!newTitle.trim()"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
class="modal-btn modal-btn-primary"
|
class="modal-btn modal-btn-primary"
|
||||||
@click="createProject"
|
@click="createProject"
|
||||||
:disabled="!newTitle.trim() || creating"
|
:disabled="!newTitle.trim() || creating"
|
||||||
@@ -316,22 +336,6 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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,
|
/* Moss action-primary per Hybrid — list-view utility action,
|
||||||
not a brand moment. Empty-state .empty-action below keeps accent. */
|
not a brand moment. Empty-state .empty-action below keeps accent. */
|
||||||
@@ -362,21 +366,12 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
border-bottom-color: var(--fs-accent);
|
border-bottom-color: var(--fs-accent);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-msg,
|
|
||||||
.error-msg {
|
.error-msg {
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
.error-msg {
|
|
||||||
color: var(--fs-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--fs-text-tertiary); }
|
.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-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 { 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); }
|
.empty-action:hover { background: var(--fs-action-primary); color: var(--fs-text-on-action); }
|
||||||
|
|
||||||
@@ -579,9 +574,6 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--fs-text-primary);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.required {
|
|
||||||
color: var(--fs-error);
|
|
||||||
}
|
|
||||||
.modal-input,
|
.modal-input,
|
||||||
.modal-textarea {
|
.modal-textarea {
|
||||||
padding: 0.45rem 0.7rem;
|
padding: 0.45rem 0.7rem;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from "vue";
|
import { ref, computed, onMounted, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
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 { useAuthStore } from "@/stores/auth";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import { useTasksStore } from "@/stores/tasks";
|
import { useTasksStore } from "@/stores/tasks";
|
||||||
@@ -11,6 +11,9 @@ import ShareDialog from "@/components/ShareDialog.vue";
|
|||||||
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
|
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
|
||||||
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
||||||
import SystemsSection from "@/components/SystemsSection.vue";
|
import SystemsSection from "@/components/SystemsSection.vue";
|
||||||
|
import InceptionCard from "@/components/InceptionCard.vue";
|
||||||
|
import { fmtDate } from "@/utils/dateFormat";
|
||||||
|
import type { InceptionDecision, InceptionRecord } from "@/api/inception";
|
||||||
import {
|
import {
|
||||||
fetchDesignSystems,
|
fetchDesignSystems,
|
||||||
setProjectDesignSystem,
|
setProjectDesignSystem,
|
||||||
@@ -50,6 +53,7 @@ interface Project {
|
|||||||
color: string | null;
|
color: string | null;
|
||||||
design_system_id: number | null;
|
design_system_id: number | null;
|
||||||
forge_connection_id: number | null;
|
forge_connection_id: number | null;
|
||||||
|
inception?: InceptionRecord | null;
|
||||||
permission?: string;
|
permission?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -75,6 +79,12 @@ interface NoteItem {
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const toast = useToastStore();
|
const toast = useToastStore();
|
||||||
|
|
||||||
|
function onInceptionDecided(decision: InceptionDecision) {
|
||||||
|
if (project.value) project.value.inception = decision.inception;
|
||||||
|
toast.show("Inheritance recorded");
|
||||||
|
void loadProject();
|
||||||
|
}
|
||||||
const tasksStore = useTasksStore();
|
const tasksStore = useTasksStore();
|
||||||
|
|
||||||
const project = ref<Project | null>(null);
|
const project = ref<Project | null>(null);
|
||||||
@@ -533,8 +543,7 @@ async function saveForgePin() {
|
|||||||
if (project.value) project.value.forge_connection_id = forgePin.value;
|
if (project.value) project.value.forge_connection_id = forgePin.value;
|
||||||
await loadCoverage();
|
await loadCoverage();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const body = (e as { body?: { error?: string } }).body;
|
toast.show(apiErrorMessage(e, "Failed to change the project's forge"), "error");
|
||||||
toast.show(body?.error || "Failed to change the project's forge", "error");
|
|
||||||
forgePin.value = project.value?.forge_connection_id ?? null;
|
forgePin.value = project.value?.forge_connection_id ?? null;
|
||||||
} finally {
|
} finally {
|
||||||
savingForgePin.value = false;
|
savingForgePin.value = false;
|
||||||
@@ -631,7 +640,7 @@ async function confirmDelete() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="project-view">
|
<main class="page-container">
|
||||||
|
|
||||||
<!-- Nav bar -->
|
<!-- Nav bar -->
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
@@ -695,6 +704,26 @@ async function confirmDelete() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Inception (milestone 297): the owner of an undecided project is asked
|
||||||
|
what it inherits; once recorded, one line says what was decided. -->
|
||||||
|
<InceptionCard
|
||||||
|
v-if="project.inception == null && isProjectOwner"
|
||||||
|
mode="decide"
|
||||||
|
:project-id="projectId"
|
||||||
|
@decided="onInceptionDecided"
|
||||||
|
/>
|
||||||
|
<p v-else-if="project.inception" class="inception-line">
|
||||||
|
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
|
||||||
|
<template v-if="project.inception.choices.exclude_always_on_rulebooks.length">
|
||||||
|
· excludes {{ project.inception.choices.exclude_always_on_rulebooks.length }} always-on rulebook(s)
|
||||||
|
</template>
|
||||||
|
<template v-if="project.inception.choices.subscribe_rulebooks.length">
|
||||||
|
· subscribes {{ project.inception.choices.subscribe_rulebooks.length }}
|
||||||
|
</template>
|
||||||
|
· design system {{ project.inception.choices.design_system_id ? "#" + project.inception.choices.design_system_id : "none" }}
|
||||||
|
<template v-if="project.inception.choices.seed_systems"> · Systems seeded</template>
|
||||||
|
</p>
|
||||||
|
|
||||||
<!-- Summary stat chips -->
|
<!-- Summary stat chips -->
|
||||||
<div v-if="project.summary" class="summary-stats">
|
<div v-if="project.summary" class="summary-stats">
|
||||||
<div class="stat-chip stat-todo">
|
<div class="stat-chip stat-todo">
|
||||||
@@ -844,15 +873,15 @@ async function confirmDelete() {
|
|||||||
paragraph in practice — this one showed as "Maintain Scribe as
|
paragraph in practice — this one showed as "Maintain Scribe as
|
||||||
the reliabl" and gave no way to read the rest without arrowing
|
the reliabl" and gave no way to read the rest without arrowing
|
||||||
through it. -->
|
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>
|
||||||
<div class="edit-field">
|
<div class="edit-field">
|
||||||
<label class="edit-label">Description</label>
|
<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>
|
||||||
<div class="edit-field">
|
<div class="edit-field">
|
||||||
<label class="edit-label">Status</label>
|
<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="active">Active</option>
|
||||||
<option value="paused">Paused</option>
|
<option value="paused">Paused</option>
|
||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
@@ -861,7 +890,7 @@ async function confirmDelete() {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="designSystems.length" class="edit-field">
|
<div v-if="designSystems.length" class="edit-field">
|
||||||
<label class="edit-label" for="project-design-system">Design system</label>
|
<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 :value="null">None</option>
|
||||||
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
|
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -1172,19 +1201,9 @@ async function confirmDelete() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* ── Layout ─────────────────────────────────────────────────── */
|
/* ── 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 ─────────────────────────────────────────────────── */
|
/* ── Nav bar ─────────────────────────────────────────────────── */
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
margin-bottom: 1.5rem; /* roomier than the shared recipe */
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
}
|
||||||
.page-header-actions { display: flex; gap: 0.5rem; align-items: center; }
|
.page-header-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
.plan-title-input {
|
.plan-title-input {
|
||||||
@@ -1197,6 +1216,7 @@ async function confirmDelete() {
|
|||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inception-line { margin: 0 0 1rem; color: var(--fs-text-secondary); font-size: 0.85rem; }
|
||||||
.project-title-input {
|
.project-title-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
@@ -1378,7 +1398,7 @@ async function confirmDelete() {
|
|||||||
/* `minmax(0, 1fr)`, not `1fr`. A bare `1fr` track has an AUTO minimum, so it
|
/* `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
|
cannot shrink below its content — one wide descendant anywhere in the
|
||||||
content column widens the whole column past the grid, and everything inside
|
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`.
|
`overflow-x: clip`.
|
||||||
This is the same property the header nav relies on and wants (neither side
|
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
|
squeezed under its content); here it is exactly wrong, because the column
|
||||||
@@ -1422,18 +1442,10 @@ async function confirmDelete() {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
.edit-input, .edit-textarea, .edit-select {
|
/* The input itself is the .fs-input canon (components.css); only the
|
||||||
padding: 0.4rem 0.6rem;
|
layout remainder lives here. */
|
||||||
border: 1px solid var(--fs-border-color);
|
.edit-textarea,
|
||||||
border-radius: var(--fs-radius-sm);
|
.edit-select { box-sizing: border-box; width: 100%; }
|
||||||
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); }
|
|
||||||
.edit-textarea { resize: vertical; }
|
.edit-textarea { resize: vertical; }
|
||||||
|
|
||||||
/* Save panel: Moss action-primary per Hybrid rule */
|
/* Save panel: Moss action-primary per Hybrid rule */
|
||||||
@@ -1802,7 +1814,7 @@ async function confirmDelete() {
|
|||||||
.note-title { font-weight: 500; min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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; }
|
.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
|
/* 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
|
load" look identical to a user, and conflating them is what let a silent
|
||||||
failure read as an empty project. */
|
failure read as an empty project. */
|
||||||
|
|||||||
+205
-511
File diff suppressed because it is too large
Load Diff
@@ -98,7 +98,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem; /* roomier than the shared recipe */
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
@@ -247,8 +247,6 @@ onMounted(async () => {
|
|||||||
.perm-admin { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning); }
|
.perm-admin { background: color-mix(in srgb, var(--fs-warning) 15%, transparent); color: var(--fs-warning); }
|
||||||
|
|
||||||
.empty-msg {
|
.empty-msg {
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,14 +220,8 @@ async function confirmDelete() {
|
|||||||
color: var(--fs-accent);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-msg {
|
.state-msg,
|
||||||
color: var(--fs-text-tertiary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
.error-msg {
|
.error-msg {
|
||||||
color: var(--fs-error);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ function cancel() {
|
|||||||
ref="nameRef"
|
ref="nameRef"
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input mono"
|
class="fs-input input mono"
|
||||||
placeholder="useDebouncedRef"
|
placeholder="useDebouncedRef"
|
||||||
@keydown.escape="cancel"
|
@keydown.escape="cancel"
|
||||||
/>
|
/>
|
||||||
@@ -239,7 +239,7 @@ function cancel() {
|
|||||||
id="sn-when"
|
id="sn-when"
|
||||||
v-model="form.when_to_use"
|
v-model="form.when_to_use"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="fs-input input"
|
||||||
placeholder="Debounce a reactive ref that updates too often"
|
placeholder="Debounce a reactive ref that updates too often"
|
||||||
@keydown.escape="cancel"
|
@keydown.escape="cancel"
|
||||||
/>
|
/>
|
||||||
@@ -253,7 +253,7 @@ function cancel() {
|
|||||||
id="sn-lang"
|
id="sn-lang"
|
||||||
v-model="form.language"
|
v-model="form.language"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="fs-input input"
|
||||||
placeholder="typescript"
|
placeholder="typescript"
|
||||||
@keydown.escape="cancel"
|
@keydown.escape="cancel"
|
||||||
/>
|
/>
|
||||||
@@ -264,7 +264,7 @@ function cancel() {
|
|||||||
id="sn-sig"
|
id="sn-sig"
|
||||||
v-model="form.signature"
|
v-model="form.signature"
|
||||||
type="text"
|
type="text"
|
||||||
class="input mono"
|
class="fs-input input mono"
|
||||||
placeholder="useDebouncedRef(value, ms)"
|
placeholder="useDebouncedRef(value, ms)"
|
||||||
@keydown.escape="cancel"
|
@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>
|
<span class="hint-inline">— where the reference implementation(s) live; a merged snippet keeps every call site</span>
|
||||||
</legend>
|
</legend>
|
||||||
<div v-for="(loc, i) in locations" :key="i" class="loc-row">
|
<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.repo" type="text" class="fs-input 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.path" type="text" class="fs-input 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.symbol" type="text" class="fs-input input mono" placeholder="symbol" aria-label="Symbol" @keydown.escape="cancel" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="loc-remove"
|
class="loc-remove"
|
||||||
@@ -296,7 +296,7 @@ function cancel() {
|
|||||||
<textarea
|
<textarea
|
||||||
id="sn-code"
|
id="sn-code"
|
||||||
v-model="form.code"
|
v-model="form.code"
|
||||||
class="input mono code-area"
|
class="fs-input input mono code-area"
|
||||||
rows="14"
|
rows="14"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
placeholder="Paste the reusable implementation…"
|
placeholder="Paste the reusable implementation…"
|
||||||
@@ -309,7 +309,7 @@ function cancel() {
|
|||||||
id="sn-tags"
|
id="sn-tags"
|
||||||
v-model="tagsText"
|
v-model="tagsText"
|
||||||
type="text"
|
type="text"
|
||||||
class="input"
|
class="fs-input input"
|
||||||
placeholder="composable, ui (comma-separated)"
|
placeholder="composable, ui (comma-separated)"
|
||||||
@keydown.escape="cancel"
|
@keydown.escape="cancel"
|
||||||
/>
|
/>
|
||||||
@@ -383,15 +383,6 @@ function cancel() {
|
|||||||
color: var(--fs-accent);
|
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 {
|
.form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -408,18 +399,12 @@ function cancel() {
|
|||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
.field-row.three {
|
|
||||||
grid-template-columns: 1fr 1.4fr 1fr;
|
|
||||||
}
|
|
||||||
.field label,
|
.field label,
|
||||||
.location-set legend {
|
.location-set legend {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--fs-text-primary);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.required {
|
|
||||||
color: var(--fs-error);
|
|
||||||
}
|
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--fs-text-tertiary);
|
color: var(--fs-text-tertiary);
|
||||||
@@ -430,21 +415,10 @@ function cancel() {
|
|||||||
color: var(--fs-text-tertiary);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* remainder over .fs-input (components.css, canon #2336; m302) */
|
||||||
.input {
|
.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%;
|
width: 100%;
|
||||||
}
|
box-sizing: border-box;
|
||||||
.input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--fs-accent);
|
|
||||||
box-shadow: var(--fs-focus-ring);
|
|
||||||
}
|
}
|
||||||
.mono {
|
.mono {
|
||||||
font-family: var(--fs-font-mono);
|
font-family: var(--fs-font-mono);
|
||||||
@@ -574,8 +548,7 @@ function cancel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.field-row,
|
.field-row {
|
||||||
.field-row.three {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="snippets-list">
|
<main class="page-container">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Snippets</h1>
|
<h1>Snippets</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
@@ -517,22 +517,10 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style src="@/assets/dup-report.css" />
|
||||||
<style scoped>
|
<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 {
|
.page-header {
|
||||||
display: flex;
|
margin-bottom: 0.35rem; /* tighter than the shared recipe: .page-sub follows */
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
.page-header h1 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
.page-sub {
|
.page-sub {
|
||||||
margin: 0 0 1.25rem;
|
margin: 0 0 1.25rem;
|
||||||
@@ -622,8 +610,6 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.error-msg {
|
.error-msg {
|
||||||
color: var(--fs-error);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,15 +624,7 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
}
|
}
|
||||||
.empty-title {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--fs-text-secondary);
|
|
||||||
margin: 0 0 0.35rem;
|
|
||||||
}
|
|
||||||
.empty-sub {
|
.empty-sub {
|
||||||
font-size: 0.85rem;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
max-width: 44ch;
|
max-width: 44ch;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -764,59 +742,6 @@ function usageTitle(s: SnippetListItem): string {
|
|||||||
color: var(--fs-text-tertiary);
|
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dup-action {
|
.dup-action {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -803,7 +803,8 @@ useEditorGuards(dirty, save);
|
|||||||
max-width: 1600px;
|
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 {
|
.task-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -823,16 +824,6 @@ useEditorGuards(dirty, save);
|
|||||||
gap: 0.75rem;
|
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
|
/* .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. */
|
gets squeezed back to min-height and overflows visibly on top of siblings. */
|
||||||
.body-editor-wrap,
|
.body-editor-wrap,
|
||||||
@@ -840,10 +831,6 @@ useEditorGuards(dirty, save);
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.body-editor-wrap {
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.preview-pane) {
|
:deep(.preview-pane) {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -949,18 +936,6 @@ useEditorGuards(dirty, save);
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.subtask-input:focus { outline: none; border-color: var(--fs-accent); }
|
.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) */
|
/* Systems multi-select (in sidebar) */
|
||||||
.sb-systems { display: flex; flex-direction: column; gap: 0.25rem; max-height: 160px; overflow-y: auto; }
|
.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; }
|
.sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--fs-text-primary); cursor: pointer; }
|
||||||
@@ -973,26 +948,11 @@ useEditorGuards(dirty, save);
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
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 {
|
.assist-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tag suggest row inside sidebar */
|
|
||||||
.tag-suggest-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.3rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lifecycle timestamps */
|
/* Lifecycle timestamps */
|
||||||
.sb-timestamps {
|
.sb-timestamps {
|
||||||
display: flex;
|
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",
|
"name": "scribe",
|
||||||
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||||
"version": "0.1.37",
|
"version": "0.1.47",
|
||||||
"author": { "name": "Bryan Van Deusen" },
|
"author": {
|
||||||
|
"name": "Bryan Van Deusen"
|
||||||
|
},
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"scribe": {
|
"scribe": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "${user_config.api_endpoint}/mcp",
|
"url": "${user_config.api_endpoint}/mcp",
|
||||||
"headers": { "Authorization": "Bearer ${user_config.api_token}" }
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${user_config.api_token}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"userConfig": {
|
"userConfig": {
|
||||||
@@ -19,7 +23,7 @@
|
|||||||
"api_token": {
|
"api_token": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Scribe API key",
|
"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
|
"sensitive": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -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.
|
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
|
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
|
snippet records the exact file being edited — "updating the record is part of
|
||||||
the edit" — each with its own once-per-session dedup.
|
the edit" — each with its own once-per-session dedup. A third, ledger-fed
|
||||||
Toggle in **Settings → Knowledge auto-inject**.
|
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.
|
- `skills/` → the universal process-skills, surfaced by description match.
|
||||||
- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync`
|
- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync`
|
||||||
command → generate `~/.claude/skills/scribe-proc-*` stubs from your Scribe
|
command → generate `~/.claude/skills/scribe-proc-*` stubs from your Scribe
|
||||||
|
|||||||
@@ -34,6 +34,17 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"PostToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Bash",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_after_write.sh\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
#!/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
|
||||||
|
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}" 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.
|
# note is injected at most once per session. Passed back as exclude_ids.
|
||||||
set -uo pipefail
|
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 jq >/dev/null 2>&1 || exit 0
|
||||||
command -v curl >/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.
|
# Nothing to retrieve against.
|
||||||
[ -n "$prompt" ] || exit 0
|
[ -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).
|
# 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.
|
# 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
|
# `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.content // .tool_input.file_content //
|
||||||
.tool_input.new_string // .tool_input.new_str // empty' 2>/dev/null) || code=""
|
.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
|
# Shared with the after-write hook (#2901): the prose/data skip list, the
|
||||||
# avoid a pointless round-trip — the server would return nothing for these
|
# definition extractor and the local by-name duplicate arm live in
|
||||||
# anyway. Config formats are NOT skipped: a CI workflow or a compose file is
|
# scribe_defs.sh so the two hooks cannot drift apart.
|
||||||
# often exactly the thing worth reusing.
|
# shellcheck source=plugin/hooks/scribe_defs.sh
|
||||||
case "$file_path" in
|
. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"
|
||||||
*.md|*.mdx|*.txt|*.rst|*.json|*.lock|*.log|*.csv|*.tsv|*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf)
|
scribe_skip_path "$file_path" && exit 0
|
||||||
exit 0 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Snippet locations are recorded repo-relative, so send a repo-relative path —
|
# Snippet locations are recorded repo-relative, so send a repo-relative path —
|
||||||
# an absolute one would simply match nothing. Resolved BEFORE the config gate
|
# an absolute one would simply match nothing. Resolved BEFORE the config gate
|
||||||
@@ -73,78 +71,8 @@ if [ -n "$repo_root" ]; then
|
|||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ARM 1 — BY NAME, LOCALLY (#2280): does a definition of this already exist
|
||||||
# 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.)
|
||||||
#
|
|
||||||
# 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
|
|
||||||
}
|
|
||||||
|
|
||||||
names=""
|
names=""
|
||||||
if [ -n "$code" ]; then
|
if [ -n "$code" ]; then
|
||||||
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12) || names=""
|
names=$(printf '%s' "$code" | scribe_defs | sort -u | head -12) || names=""
|
||||||
@@ -152,21 +80,8 @@ fi
|
|||||||
|
|
||||||
local_lines=""
|
local_lines=""
|
||||||
if [ -n "$repo_root" ] && [ -n "$names" ]; then
|
if [ -n "$repo_root" ] && [ -n "$names" ]; then
|
||||||
while IFS=$'\t' read -r kind name; do
|
local_lines=$(scribe_local_dups "$repo_root" "$rel_path" <<< "$names") || local_lines=""
|
||||||
[ -n "${name:-}" ] || continue
|
[ -n "$local_lines" ] && local_lines="${local_lines}"$'\n'
|
||||||
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"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local_context=""
|
local_context=""
|
||||||
@@ -206,11 +121,7 @@ if [ -n "$shapes" ]; then
|
|||||||
[ -n "$enc" ] && shapes_q="&shapes=${enc}"
|
[ -n "$enc" ] && shapes_q="&shapes=${enc}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
scribe_config || : # sets url/token; unconfigured is handled just below
|
||||||
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 → the recorded-prior-art arms are skipped, but the local
|
# Unconfigured install → the recorded-prior-art arms are skipped, but the local
|
||||||
# arm above already ran and may have something to say.
|
# arm above already ran and may have something to say.
|
||||||
if [ -z "$url" ] || [ -z "$token" ]; then
|
if [ -z "$url" ] || [ -z "$token" ]; then
|
||||||
@@ -258,14 +169,29 @@ fi
|
|||||||
# the sync nudge when the recorded file itself is edited later.
|
# the sync nudge when the recorded file itself is edited later.
|
||||||
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
||||||
mkdir -p "$state_dir" 2>/dev/null || true
|
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=""
|
idfile=""
|
||||||
syncfile=""
|
syncfile=""
|
||||||
|
derivefile=""
|
||||||
|
rulefile=""
|
||||||
exclude_q=""
|
exclude_q=""
|
||||||
sync_exclude_q=""
|
sync_exclude_q=""
|
||||||
|
derive_exclude_q=""
|
||||||
|
rule_exclude_q=""
|
||||||
if [ -n "$session_id" ]; then
|
if [ -n "$session_id" ]; then
|
||||||
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
||||||
idfile="$state_dir/${safe_sid}.ids"
|
idfile="$state_dir/${safe_sid}.ids"
|
||||||
syncfile="$state_dir/${safe_sid}.sync.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
|
if [ -f "$idfile" ]; then
|
||||||
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
||||||
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
[ -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/,$//')
|
sync_seen=$(tr '\n' ',' < "$syncfile" 2>/dev/null | sed 's/,$//')
|
||||||
[ -n "$sync_seen" ] && sync_exclude_q="&exclude_sync_ids=${sync_seen}"
|
[ -n "$sync_seen" ] && sync_exclude_q="&exclude_sync_ids=${sync_seen}"
|
||||||
fi
|
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
|
fi
|
||||||
|
|
||||||
# `|| true`, not `|| exit 0`: an unreachable instance must not discard a local
|
# Not `|| exit 0`: an unreachable instance must not discard a local finding
|
||||||
# finding that needed no instance to produce.
|
# 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 \
|
body=$(curl -fsS --max-time 5 \
|
||||||
-H "Authorization: Bearer ${token}" \
|
-H "Authorization: Bearer ${token}" \
|
||||||
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}${sync_exclude_q}${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=""
|
context=""
|
||||||
if [ -n "$body" ]; then
|
if [ -n "$body" ]; then
|
||||||
@@ -295,6 +238,12 @@ if [ -n "$body" ]; then
|
|||||||
if [ -n "$syncfile" ]; then
|
if [ -n "$syncfile" ]; then
|
||||||
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
|
printf '%s' "$body" | jq -r '(.sync_note_ids // [])[]?' 2>/dev/null >> "$syncfile" || true
|
||||||
fi
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -304,9 +253,10 @@ fi
|
|||||||
# noise: the duplication is demonstrated, not guessed. Gated on BOTH sides so
|
# 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
|
# 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
|
# server spoke) stay nudge-free — a reflex that fires on everything is one
|
||||||
# that gets skipped. An unreachable server counts as "nothing recorded": the
|
# that gets skipped. A server that did not ANSWER earns no nudge (#2932): "none
|
||||||
# local finding needed no server, and the nudge fails open with it.
|
# of those copies is recorded" is a claim only an answer can back — the
|
||||||
if [ -n "$local_lines" ]; then
|
# 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
|
n_recorded=$(printf '%s' "$body" | jq -r '.note_ids | length' 2>/dev/null) || n_recorded=0
|
||||||
if [ "${n_recorded:-0}" = "0" ] || [ "$n_recorded" = "" ]; then
|
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."
|
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'
|
[ -n "$combined" ] && combined="${combined}"$'\n'
|
||||||
combined="${combined}${context}"
|
combined="${combined}${context}"
|
||||||
fi
|
fi
|
||||||
|
if [ -n "$unreached_context" ]; then
|
||||||
|
[ -n "$combined" ] && combined="${combined}"$'\n'
|
||||||
|
combined="${combined}${unreached_context}"
|
||||||
|
fi
|
||||||
[ -n "$combined" ] || exit 0
|
[ -n "$combined" ] || exit 0
|
||||||
|
|
||||||
# No permissionDecision: this is a nudge, not a gate. The write goes ahead.
|
# 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.
|
# allowed to fail quietly; see the #2198 comment at the status block below.
|
||||||
set -uo pipefail
|
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
|
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`
|
# `CDPATH= cd` is deliberate, not a typo'd assignment: it runs this one `cd`
|
||||||
@@ -87,13 +90,9 @@ if [ -f "$manifest" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
|
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
|
||||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
# Unconfigured is NOT a failure here: tier 1's static floor is still owed,
|
||||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
# so this records the answer rather than acting on it.
|
||||||
|
scribe_config || :
|
||||||
# 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
|
|
||||||
|
|
||||||
dyn=""
|
dyn=""
|
||||||
status=""
|
status=""
|
||||||
|
|||||||
@@ -66,7 +66,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:
|
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
|
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
|
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
|
- 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.
|
memory / CLAUDE.md in parallel with Scribe — Scribe holds the single copy.
|
||||||
- **Compact at clean seams** — because you record as you go, a context
|
- **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.
|
# #2198), with SCRIBE_URL / SCRIBE_TOKEN as the override.
|
||||||
set -uo pipefail
|
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 jq >/dev/null 2>&1 || exit 0
|
||||||
command -v curl >/dev/null 2>&1 || exit 0
|
command -v curl >/dev/null 2>&1 || exit 0
|
||||||
|
|
||||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
scribe_config || exit 0
|
||||||
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
|
|
||||||
|
|
||||||
body=$(curl -fsS --max-time 8 \
|
body=$(curl -fsS --max-time 8 \
|
||||||
-H "Authorization: Bearer ${token}" \
|
-H "Authorization: Bearer ${token}" \
|
||||||
|
|||||||
@@ -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
|
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
|
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.
|
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
|
- **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
|
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,
|
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
|
the rest as instances. Canon is determined from the code; consistency comes
|
||||||
from the derivation, not from asking permission.
|
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
|
## The divergence readout — button B where button A is canon
|
||||||
|
|
||||||
Three questions the ledger answers mechanically (#2793):
|
Three questions the ledger answers mechanically (#2793):
|
||||||
|
|||||||
@@ -120,6 +120,28 @@ bound — confine the session to it:
|
|||||||
- If something clearly belongs to a *different* project, say so and **ask before
|
- If something clearly belongs to a *different* project, say so and **ask before
|
||||||
switching** — never silently operate cross-project.
|
switching** — never silently operate cross-project.
|
||||||
|
|
||||||
|
## Starting a project: decide what it inherits
|
||||||
|
|
||||||
|
A project's inheritance is a **decision, not a default**. Before
|
||||||
|
`create_project`, ask the operator the four inception questions and pass the
|
||||||
|
answers — never create a project bare by default:
|
||||||
|
|
||||||
|
- which **always-on rulebooks** it should NOT inherit (`list_rulebooks` shows
|
||||||
|
which are always_on; default: inherit them all) →
|
||||||
|
`exclude_always_on_rulebooks=[...]`
|
||||||
|
- which other rulebooks to **subscribe** → `subscribe_rulebooks=[...]`
|
||||||
|
- which **design system** its UI is built from (`list_design_systems`; or
|
||||||
|
none) → `design_system_id=<id | -1>`
|
||||||
|
- whether to **seed the standard starter Systems** so records can be tagged
|
||||||
|
from day one → `seed_systems=true|false`
|
||||||
|
|
||||||
|
If `enter_project` returns an `inception` key, the project was never decided
|
||||||
|
(it inherits its defaults silently): raise that ask once, with the defaults it
|
||||||
|
carries, then `decide_project_inception(project_id, …)`. Existing projects
|
||||||
|
were stamped "legacy" (inherit-all) and do not ask; any project can be
|
||||||
|
re-decided. The rules/design-system/Systems tools still work one at a time —
|
||||||
|
inception is the moment they are decided together, and the record of why.
|
||||||
|
|
||||||
## Where a new rule goes
|
## Where a new rule goes
|
||||||
|
|
||||||
When codifying a rule, pick its home by **who it should bind** — and keep
|
When codifying a rule, pick its home by **who it should bind** — and keep
|
||||||
|
|||||||
+47
-8
@@ -159,7 +159,12 @@ def check_shellcheck() -> None:
|
|||||||
return
|
return
|
||||||
for script in hook_scripts():
|
for script in hook_scripts():
|
||||||
proc = subprocess.run(
|
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,
|
capture_output=True, text=True,
|
||||||
)
|
)
|
||||||
rel = script.relative_to(ROOT)
|
rel = script.relative_to(ROOT)
|
||||||
@@ -172,15 +177,24 @@ def check_shellcheck() -> None:
|
|||||||
# --- the fail-open contract ------------------------------------------------
|
# --- the fail-open contract ------------------------------------------------
|
||||||
|
|
||||||
# Every hook promises never to break the operator's session: unconfigured or
|
# Every hook promises never to break the operator's session: unconfigured or
|
||||||
# unreachable, it exits 0. Three of them additionally promise SILENCE, because
|
# unreachable, it exits 0. Unconfigured, the enrichment hooks are SILENT — no
|
||||||
# they are pure enrichment. scribe_session_context.sh is the exception by
|
# call was owed. scribe_session_context.sh is the exception by design — it
|
||||||
# design — it always emits a static behavioural floor that needs no credentials
|
# always emits a static behavioural floor that needs no credentials and no
|
||||||
# and no network, so "silent" would be the wrong assertion for it.
|
# 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
|
# 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: the bug and the healthy no-results case looked identical from
|
||||||
# Pinning it does NOT make the failure visible; it makes sure the fail-open
|
# outside. #2932 is what finally makes the failure visible at the write; this
|
||||||
# behaviour is deliberate rather than accidental.
|
# check makes sure the fail-open behaviour stays deliberate rather than
|
||||||
|
# accidental.
|
||||||
# A symbol that exists nowhere, ASSEMBLED rather than written literally.
|
# A symbol that exists nowhere, ASSEMBLED rather than written literally.
|
||||||
# The prior-art hook's local arm (#2280) fires with no credentials, so the
|
# 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,
|
# silence assertion below needs a name the repo genuinely lacks. Two traps,
|
||||||
@@ -203,10 +217,24 @@ SMOKE_EVENTS: dict[str, str] = {
|
|||||||
),
|
),
|
||||||
"scribe_sync_processes.sh": json.dumps({"source": "startup"}),
|
"scribe_sync_processes.sh": json.dumps({"source": "startup"}),
|
||||||
"scribe_session_context.sh": json.dumps({"source": "startup"}),
|
"scribe_session_context.sh": json.dumps({"source": "startup"}),
|
||||||
|
# The after-write hook (#2901) diffs the working tree; on CI's clean
|
||||||
|
# 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.
|
# The one hook that legitimately produces output with no credentials.
|
||||||
STATIC_FLOOR = "scribe_session_context.sh"
|
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:
|
def _run_hook(script: Path, event: str, env_extra: dict[str, str]) -> subprocess.CompletedProcess:
|
||||||
@@ -258,6 +286,17 @@ def check_fail_open() -> None:
|
|||||||
f"behavioural floor must survive having no credentials")
|
f"behavioural floor must survive having no credentials")
|
||||||
else:
|
else:
|
||||||
ok(f"{rel} [{label}]: exit 0, static floor present")
|
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:
|
elif out:
|
||||||
fail(f"{rel} [{label}]: emitted output with no working instance:\n"
|
fail(f"{rel} [{label}]: emitted output with no working instance:\n"
|
||||||
f" {out[:200]}")
|
f" {out[:200]}")
|
||||||
|
|||||||
+9
-1
@@ -30,6 +30,7 @@ from scribe.routes.design_systems import design_systems_bp
|
|||||||
from scribe.routes.trash import trash_bp
|
from scribe.routes.trash import trash_bp
|
||||||
from scribe.routes.dashboard import dashboard_bp
|
from scribe.routes.dashboard import dashboard_bp
|
||||||
from scribe.routes.systems import systems_bp
|
from scribe.routes.systems import systems_bp
|
||||||
|
from scribe.routes.canonical_systems import canonical_systems_bp
|
||||||
from scribe.routes.snippets import snippets_bp
|
from scribe.routes.snippets import snippets_bp
|
||||||
from scribe.routes.webhooks import webhooks_bp
|
from scribe.routes.webhooks import webhooks_bp
|
||||||
from scribe.mcp import mount_mcp
|
from scribe.mcp import mount_mcp
|
||||||
@@ -95,6 +96,7 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(trash_bp)
|
app.register_blueprint(trash_bp)
|
||||||
app.register_blueprint(dashboard_bp)
|
app.register_blueprint(dashboard_bp)
|
||||||
app.register_blueprint(systems_bp)
|
app.register_blueprint(systems_bp)
|
||||||
|
app.register_blueprint(canonical_systems_bp)
|
||||||
app.register_blueprint(snippets_bp)
|
app.register_blueprint(snippets_bp)
|
||||||
app.register_blueprint(webhooks_bp)
|
app.register_blueprint(webhooks_bp)
|
||||||
|
|
||||||
@@ -159,7 +161,7 @@ def create_app() -> Quart:
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from scribe.services.auth import start_auth_token_retention_loop
|
from scribe.services.auth import start_auth_token_retention_loop
|
||||||
from scribe.services.embeddings import backfill_note_embeddings
|
from scribe.services.embeddings import backfill_note_embeddings, backfill_rule_embeddings
|
||||||
from scribe.services.logging import start_log_retention_loop
|
from scribe.services.logging import start_log_retention_loop
|
||||||
from scribe.services.notifications import start_notification_loop
|
from scribe.services.notifications import start_notification_loop
|
||||||
|
|
||||||
@@ -174,6 +176,12 @@ def create_app() -> Quart:
|
|||||||
await backfill_note_embeddings()
|
await backfill_note_embeddings()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Embedding backfill failed", exc_info=True)
|
logger.warning("Embedding backfill failed", exc_info=True)
|
||||||
|
# Rules got vectors in milestone 307; every rule written before it
|
||||||
|
# has none, so this is the pass that makes them findable at all.
|
||||||
|
try:
|
||||||
|
await backfill_rule_embeddings()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Rule embedding backfill failed", exc_info=True)
|
||||||
# Snippets written before migration 0070 have no `notes.data` mirror,
|
# Snippets written before migration 0070 have no `notes.data` mirror,
|
||||||
# and the location reverse lookup queries that column — an unfilled
|
# and the location reverse lookup queries that column — an unfilled
|
||||||
# row would read as "no snippet here" rather than as a gap. Separate
|
# row would read as "no snippet here" rather than as a gap. Separate
|
||||||
|
|||||||
@@ -37,24 +37,23 @@ in local files (CLAUDE.md, auto-memory); Scribe holds the single copy.
|
|||||||
|
|
||||||
Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
|
Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose:
|
||||||
- ORIENT: enter_project(id) at session start — rules, open tasks, recent
|
- ORIENT: enter_project(id) at session start — rules, open tasks, recent
|
||||||
notes, Systems and design system in one call.
|
notes, Systems, design system. `inception` key: ask what the project
|
||||||
|
inherits, decide_project_inception (create_project takes the same).
|
||||||
- DO: create_task. Fixed a problem? kind="issue" (symptom -> root cause ->
|
- DO: create_task. Fixed a problem? kind="issue" (symptom -> root cause ->
|
||||||
fix), never a work-log line on an unrelated task. Log with add_task_log;
|
fix), never a work-log line on an unrelated task. Log with add_task_log;
|
||||||
keep status honest — in_progress on start, done on finish.
|
keep status honest — in_progress on start, done on finish.
|
||||||
- PLAN work with an arc: start_planning. The plan IS a milestone; each step is
|
- PLAN work with an arc: start_planning. The plan IS a milestone; each step is
|
||||||
a child task, not a checkbox. No local plan .md files.
|
a child task, not a checkbox. No local plan .md files.
|
||||||
- CAPTURE: create_note. RECALL: search first, before answering about the
|
- CAPTURE: create_note. RECALL: search first — prior art exists; pass the
|
||||||
operator's work or opening a task — assume prior art exists, and pass the
|
|
||||||
active project_id to stay in scope.
|
active project_id to stay in scope.
|
||||||
- WHERE work happens: Systems. Tag records with system_ids as you write;
|
- WHERE work happens: Systems. Tag records with system_ids as you write;
|
||||||
create_system when the area is unmodelled.
|
create_system when the area is unmodelled.
|
||||||
- HOW to work: rules are pull-only and binding — call list_always_on_rules()
|
- HOW: rules are binding — list_always_on_rules() at session start.
|
||||||
yourself at session start.
|
|
||||||
- UI: the project's design system is binding — resolve_design_system /
|
- UI: the project's design system is binding — resolve_design_system /
|
||||||
get_design_system_stylesheet before hand-writing a value.
|
get_design_system_stylesheet before hand-writing a value.
|
||||||
- REUSE: search snippets before writing a helper; record what you build with
|
- REUSE: search snippets before writing a helper; record what you build with
|
||||||
create_snippet; classify shapes against canon (classify_shapes) — a
|
create_snippet; classify shapes against canon (classify_shapes) — a
|
||||||
consumer map is rows, never prose. Saved procedures are Processes (follow
|
consumer map is rows, never prose. Processes are saved procedures (follow
|
||||||
verbatim). Deletes are trash-recoverable.
|
verbatim). Deletes are trash-recoverable.
|
||||||
|
|
||||||
A task is a note with status (*_note vs *_task tools).
|
A task is a note with status (*_note vs *_task tools).
|
||||||
@@ -92,6 +91,9 @@ _READ_ONLY_TOOLS = frozenset({
|
|||||||
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
|
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
|
||||||
"list_always_on_rules", "search",
|
"list_always_on_rules", "search",
|
||||||
"get_system", "list_systems", "list_system_records",
|
"get_system", "list_systems", "list_system_records",
|
||||||
|
# The global area catalog and its mapping REPORT — propose writes nothing;
|
||||||
|
# map_system_to_canonical is the separate, explicitly-called write.
|
||||||
|
"list_canonical_systems", "propose_canonical_mappings",
|
||||||
# Reports on the corpus. Reads only — the merge or supersession each
|
# Reports on the corpus. Reads only — the merge or supersession each
|
||||||
# suggests is a separate, explicitly-called write.
|
# suggests is a separate, explicitly-called write.
|
||||||
"find_duplicate_snippets", "find_duplicate_records",
|
"find_duplicate_snippets", "find_duplicate_records",
|
||||||
@@ -113,6 +115,11 @@ _READ_ONLY_TOOLS = frozenset({
|
|||||||
# The shape ledger's todo query (#2789). Reads only — classify_shapes is
|
# The shape ledger's todo query (#2789). Reads only — classify_shapes is
|
||||||
# the write, and it is deliberately NOT here.
|
# the write, and it is deliberately NOT here.
|
||||||
"list_shapes", "shape_history",
|
"list_shapes", "shape_history",
|
||||||
|
# The retrieval telemetry readout (#2975). Aggregates two log tables and
|
||||||
|
# writes nothing. Listed explicitly because its name carries no read
|
||||||
|
# prefix, so the completeness test below cannot derive it — the same
|
||||||
|
# reason `enter_project` is spelled out above.
|
||||||
|
"retrieval_telemetry",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Read-SHAPED tools that must NOT be reachable with a read key — a getter that
|
# Read-SHAPED tools that must NOT be reachable with a read key — a getter that
|
||||||
|
|||||||
@@ -15,13 +15,17 @@ from scribe.services import trash as trash_svc
|
|||||||
from scribe.services.note_usage import record_pulled
|
from scribe.services.note_usage import record_pulled
|
||||||
|
|
||||||
|
|
||||||
async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
|
async def list_processes(
|
||||||
|
q: str = "", tag: str = "", limit: int = 50, offset: int = 0,
|
||||||
|
) -> dict:
|
||||||
"""List stored processes (reusable saved prompts).
|
"""List stored processes (reusable saved prompts).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
q: Free-text search across title + body (optional).
|
q: Free-text search across title + body (optional).
|
||||||
tag: Filter to a single tag (optional).
|
tag: Filter to a single tag (optional).
|
||||||
limit: Max results (1-100).
|
limit: Max results (1-100).
|
||||||
|
offset: Skip this many before returning — page past the cap.
|
||||||
|
`total` is the unpaged count, so it says whether more remains.
|
||||||
|
|
||||||
Returns {"processes": [{id, title, tags, preview}], "total": int}. An entry
|
Returns {"processes": [{id, title, tags, preview}], "total": int}. An entry
|
||||||
marked `shared: true` with an `owner` is another person's procedure — treat
|
marked `shared: true` with an `owner` is another person's procedure — treat
|
||||||
@@ -34,7 +38,8 @@ async def list_processes(q: str = "", tag: str = "", limit: int = 50) -> dict:
|
|||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
items, total = await knowledge_svc.query_knowledge(
|
items, total = await knowledge_svc.query_knowledge(
|
||||||
user_id=uid, note_type="process", tags=[tag] if tag else [],
|
user_id=uid, note_type="process", tags=[tag] if tag else [],
|
||||||
sort="modified", q=q or None, limit=max(1, min(limit, 100)), offset=0,
|
sort="modified", q=q or None, limit=max(1, min(limit, 100)),
|
||||||
|
offset=max(0, offset),
|
||||||
)
|
)
|
||||||
labelled = await access_svc.label_shared_items(uid, items)
|
labelled = await access_svc.label_shared_items(uid, items)
|
||||||
procs = [{"id": it["id"], "title": it["title"], "tags": it.get("tags", []),
|
procs = [{"id": it["id"], "title": it["title"], "tags": it.get("tags", []),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from scribe.mcp._context import current_user_id
|
|||||||
from scribe.mcp.tools import systems as systems_tools
|
from scribe.mcp.tools import systems as systems_tools
|
||||||
from scribe.services import coverage as coverage_svc
|
from scribe.services import coverage as coverage_svc
|
||||||
from scribe.services import design_systems as design_systems_svc
|
from scribe.services import design_systems as design_systems_svc
|
||||||
|
from scribe.services import inception as inception_svc
|
||||||
from scribe.services import milestones as milestones_svc
|
from scribe.services import milestones as milestones_svc
|
||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
from scribe.services import projects as projects_svc
|
from scribe.services import projects as projects_svc
|
||||||
@@ -80,6 +81,12 @@ async def enter_project(project_id: int) -> dict:
|
|||||||
create it with create_system rather than leaving the area unmodelled. Read
|
create it with create_system rather than leaving the area unmodelled. Read
|
||||||
a subsystem's accumulated records with list_system_records.
|
a subsystem's accumulated records with list_system_records.
|
||||||
|
|
||||||
|
`inception` (milestone 297) appears ONLY when the project is yours and
|
||||||
|
nobody has decided what it inherits: it carries the current defaults
|
||||||
|
(which always-on rulebooks bind, design system, Systems), what to ask the
|
||||||
|
operator — once — and the decide_project_inception call that answers it;
|
||||||
|
it repeats on every enter until a decision is recorded.
|
||||||
|
|
||||||
`systems_bootstrap` appears ONLY when the project has many records and no
|
`systems_bootstrap` appears ONLY when the project has many records and no
|
||||||
Systems at all — act on it before starting other work: create_system a
|
Systems at all — act on it before starting other work: create_system a
|
||||||
starter vocabulary from the areas the project's records name, directly
|
starter vocabulary from the areas the project's records name, directly
|
||||||
@@ -141,6 +148,14 @@ async def enter_project(project_id: int) -> dict:
|
|||||||
uid, project_id
|
uid, project_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# The inception ask (milestone 297): a project nobody has decided on
|
||||||
|
# inherits its defaults silently — always-on rulebooks, no design system,
|
||||||
|
# no Systems. Owner-only (deciding is the owner's), and only until a
|
||||||
|
# decision is recorded; the key is ABSENT otherwise (#2483).
|
||||||
|
inception_ask = None
|
||||||
|
if project.user_id == uid and not inception_svc.is_decided(project):
|
||||||
|
inception_ask = await inception_svc.inception_ask(uid, project_id)
|
||||||
|
|
||||||
# Probably the largest surfacing by volume, and it emitted nothing — so
|
# Probably the largest surfacing by volume, and it emitted nothing — so
|
||||||
# the pulls it caused floated unattributed and the surfaced:pulled ratio
|
# the pulls it caused floated unattributed and the surfaced:pulled ratio
|
||||||
# ran against a denominator missing its biggest contributor (#2477). An
|
# ran against a denominator missing its biggest contributor (#2477). An
|
||||||
@@ -213,6 +228,8 @@ async def enter_project(project_id: int) -> dict:
|
|||||||
# readers to skip it (#2483), and this one exists to be acted on.
|
# readers to skip it (#2483), and this one exists to be acted on.
|
||||||
if systems_bootstrap:
|
if systems_bootstrap:
|
||||||
out["systems_bootstrap"] = systems_bootstrap
|
out["systems_bootstrap"] = systems_bootstrap
|
||||||
|
if inception_ask:
|
||||||
|
out["inception"] = inception_ask
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -238,14 +255,43 @@ async def get_project(project_id: int) -> dict:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _inception_choices(
|
||||||
|
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
|
||||||
|
) -> dict | None:
|
||||||
|
"""The tool args → an inception choices object, or None when no inception
|
||||||
|
arg was given at all (a bare create stays undecided and enter_project
|
||||||
|
asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
|
||||||
|
system."""
|
||||||
|
if (exclude_always_on_rulebooks is None and subscribe_rulebooks is None
|
||||||
|
and not design_system_id and seed_systems is None):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"exclude_always_on_rulebooks": list(exclude_always_on_rulebooks or []),
|
||||||
|
"subscribe_rulebooks": list(subscribe_rulebooks or []),
|
||||||
|
"design_system_id": None if design_system_id in (0, -1) else design_system_id,
|
||||||
|
"seed_systems": bool(seed_systems),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def create_project(
|
async def create_project(
|
||||||
title: str,
|
title: str,
|
||||||
description: str = "",
|
description: str = "",
|
||||||
goal: str = "",
|
goal: str = "",
|
||||||
status: str = "active",
|
status: str = "active",
|
||||||
color: str = "",
|
color: str = "",
|
||||||
|
exclude_always_on_rulebooks: list[int] | None = None,
|
||||||
|
subscribe_rulebooks: list[int] | None = None,
|
||||||
|
design_system_id: int = 0,
|
||||||
|
seed_systems: bool | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new project in Scribe.
|
"""Create a new project in Scribe — and decide what it inherits.
|
||||||
|
|
||||||
|
A project's inheritance is a decision, not a default (milestone 297):
|
||||||
|
before calling, ask the operator the four inception questions and pass
|
||||||
|
the answers; a project created without any of them is UNDECIDED and
|
||||||
|
enter_project will ask until decide_project_inception records it.
|
||||||
|
Defaults if nobody decides: every always-on rulebook binds, nothing is
|
||||||
|
subscribed, no design system, no Systems.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
title: Project name (required).
|
title: Project name (required).
|
||||||
@@ -253,6 +299,14 @@ async def create_project(
|
|||||||
goal: The desired outcome or definition of done for the project.
|
goal: The desired outcome or definition of done for the project.
|
||||||
status: one of active (default), paused, completed, archived.
|
status: one of active (default), paused, completed, archived.
|
||||||
color: Optional hex colour for the project card (e.g. "#6366f1").
|
color: Optional hex colour for the project card (e.g. "#6366f1").
|
||||||
|
exclude_always_on_rulebooks: always-on rulebook ids this project does
|
||||||
|
NOT inherit ([] = inherit them all). list_rulebooks shows which are
|
||||||
|
always_on.
|
||||||
|
subscribe_rulebooks: rulebook ids to subscribe (the non-always-on ones).
|
||||||
|
design_system_id: the design system this project's UI is built from
|
||||||
|
(list_design_systems); -1 = explicitly none; 0 = not stated.
|
||||||
|
seed_systems: true mints the standard starter Systems (CI & Release,
|
||||||
|
Auth & Access, …) so records can be tagged from day one.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
project = await projects_svc.create_project(
|
project = await projects_svc.create_project(
|
||||||
@@ -263,7 +317,52 @@ async def create_project(
|
|||||||
status=status,
|
status=status,
|
||||||
color=color or None,
|
color=color or None,
|
||||||
)
|
)
|
||||||
return project.to_dict()
|
data = project.to_dict()
|
||||||
|
choices = _inception_choices(
|
||||||
|
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
|
||||||
|
)
|
||||||
|
if choices is not None:
|
||||||
|
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
|
||||||
|
data["inception"] = decided["inception"]
|
||||||
|
data["inception_effects"] = decided["effects"]
|
||||||
|
else:
|
||||||
|
data["inception_hint"] = (
|
||||||
|
"Undecided: this project inherits its defaults until "
|
||||||
|
"decide_project_inception records what it should inherit "
|
||||||
|
"(enter_project will ask)."
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
async def decide_project_inception(
|
||||||
|
project_id: int,
|
||||||
|
exclude_always_on_rulebooks: list[int] | None = None,
|
||||||
|
subscribe_rulebooks: list[int] | None = None,
|
||||||
|
design_system_id: int = 0,
|
||||||
|
seed_systems: bool | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Record what a project inherits — answer enter_project's `inception` ask,
|
||||||
|
or re-decide later (milestone 297).
|
||||||
|
|
||||||
|
Owner-only. Applies the effects through the ordinary tools' paths —
|
||||||
|
exclude_always_on_rulebook, subscribe_project_to_rulebook,
|
||||||
|
set_project_design_system, the standard Systems seed — and writes the
|
||||||
|
decision on the project last, so get_project/enter_project can say why
|
||||||
|
the project has the rules, design and Systems it has. Re-deciding is
|
||||||
|
additive for exclusions/subscriptions (use include_always_on_rulebook /
|
||||||
|
unsubscribe_project_from_rulebook to undo one), replaces the design
|
||||||
|
system, and never re-seeds Systems a project already has.
|
||||||
|
|
||||||
|
Args: as create_project's inception args. Passing nothing records an
|
||||||
|
inherit-all decision (every always-on rulebook binds, no subscriptions,
|
||||||
|
no design system, no seed) — a valid answer, stated.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
choices = _inception_choices(
|
||||||
|
exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems,
|
||||||
|
) or {}
|
||||||
|
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
|
||||||
|
return {"project_id": project_id, **decided}
|
||||||
|
|
||||||
|
|
||||||
async def update_project(
|
async def update_project(
|
||||||
@@ -320,6 +419,6 @@ def register(mcp) -> None:
|
|||||||
get_project,
|
get_project,
|
||||||
create_project,
|
create_project,
|
||||||
update_project,
|
update_project,
|
||||||
delete_project,
|
delete_project, decide_project_inception,
|
||||||
):
|
):
|
||||||
mcp.tool(name=fn.__name__)(fn)
|
mcp.tool(name=fn.__name__)(fn)
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
"""MCP tools for the Scribe Rulebook system.
|
"""MCP tools for the Scribe Rulebook system.
|
||||||
|
|
||||||
Sixteen tools: rulebook/topic/rule CRUD + subscription management. Thin
|
Rulebook / topic / rule CRUD, subscription management, and the rule-to-rule
|
||||||
wrappers over services/rulebooks.py — ownership is enforced in the service.
|
edges. Thin wrappers over services/rulebooks.py — ownership is enforced in the
|
||||||
|
service, and the record shape comes from rule_brief / rule_detail there rather
|
||||||
|
than being rebuilt here.
|
||||||
|
|
||||||
|
(The header used to say "Sixteen tools" and had been wrong for two milestones;
|
||||||
|
the count lives in the registration test, which fails when it drifts.)
|
||||||
|
|
||||||
Destructive ops (delete_*) require confirmed=True; otherwise return a
|
Destructive ops (delete_*) require confirmed=True; otherwise return a
|
||||||
preview-style warning. Mirrors the pattern in delete_event and the design
|
preview-style warning. Mirrors the pattern in delete_event and the design
|
||||||
@@ -195,8 +200,12 @@ async def delete_topic(topic_id: int, confirmed: bool = False) -> dict:
|
|||||||
|
|
||||||
def _rule_summary(r) -> dict:
|
def _rule_summary(r) -> dict:
|
||||||
"""The list-row shape for a rule: what an agent needs to APPLY it. The
|
"""The list-row shape for a rule: what an agent needs to APPLY it. The
|
||||||
full record (why, how_to_apply, timestamps) is get_rule's job."""
|
full record (why, how_to_apply, timestamps) is get_rule's job.
|
||||||
return {"id": r.id, "title": r.title, "statement": r.statement, "topic_id": r.topic_id}
|
|
||||||
|
One line, because the shape itself lives in the service — this was one of
|
||||||
|
three hand-written copies that had already drifted apart (note 3026).
|
||||||
|
"""
|
||||||
|
return rulebooks_svc.rule_brief(r)
|
||||||
|
|
||||||
|
|
||||||
async def list_rules(
|
async def list_rules(
|
||||||
@@ -222,32 +231,52 @@ async def list_rules(
|
|||||||
return {"rules": [_rule_summary(r) for r in rows], "total": len(rows)}
|
return {"rules": [_rule_summary(r) for r in rows], "total": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
async def list_always_on_rules() -> dict:
|
async def list_always_on_rules(project_id: int = 0) -> dict:
|
||||||
"""Return all rules from rulebooks flagged always_on for the current user.
|
"""Return all rules from rulebooks flagged always_on for the current user.
|
||||||
|
|
||||||
Call this at session start. Treat the returned rules as binding for the
|
Call this at session start. Treat the returned rules as binding for the
|
||||||
session — they apply regardless of which project (if any) is in scope.
|
session — they apply regardless of which project (if any) is in scope.
|
||||||
|
|
||||||
|
Returns the ALWAYS-ON tier only (milestone 307). A `conditional` rule is
|
||||||
|
still binding when it applies; it just is not resident — it reaches a
|
||||||
|
session through enter_project (when the project works in an area the rule
|
||||||
|
is tagged to) or through search(content_type="rule"). Nothing here is a
|
||||||
|
behaviour change until rules are actually re-tiered: `tier` defaults to
|
||||||
|
always_on, so an existing rulebook returns exactly what it always did.
|
||||||
Pair with get_project(id).applicable_rules when working on a specific
|
Pair with get_project(id).applicable_rules when working on a specific
|
||||||
project to also load that project's subscription-derived rules.
|
project to also load that project's subscription-derived rules.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: 0 (default) = the user-wide set. Inside a project, pass
|
||||||
|
its id: an always-on rulebook the project EXCLUDED at inception
|
||||||
|
(see enter_project's `excluded_always_on`) is left out — the
|
||||||
|
project decided not to inherit it.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
rules = await rulebooks_svc.list_always_on_rules(uid)
|
rules = await rulebooks_svc.list_always_on_rules(uid, project_id=project_id)
|
||||||
return {"rules": [_rule_summary(r) for r in rules], "total": len(rules)}
|
return {"rules": [_rule_summary(r) for r in rules], "total": len(rules)}
|
||||||
|
|
||||||
|
|
||||||
async def get_rule(rule_id: int) -> dict:
|
async def get_rule(rule_id: int) -> dict:
|
||||||
"""Fetch a rule by id — full statement + why + how_to_apply."""
|
"""Fetch a rule by id — full statement + why + how_to_apply.
|
||||||
|
|
||||||
|
Also carries what a listing leaves out: the global `systems` this rule is
|
||||||
|
about, and its `relations`. Read the relations before acting on the rule —
|
||||||
|
a rule with a `co_surfaces` edge is half of a shape, and an `overrides`
|
||||||
|
edge means one of the pair is not in force here.
|
||||||
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
||||||
if rule is None:
|
if rule is None:
|
||||||
raise ValueError(f"rule {rule_id} not found")
|
raise ValueError(f"rule {rule_id} not found")
|
||||||
return rule.to_dict()
|
return await rulebooks_svc.rule_detail(uid, rule)
|
||||||
|
|
||||||
|
|
||||||
async def create_rule(
|
async def create_rule(
|
||||||
topic_id: int, title: str, statement: str,
|
topic_id: int, title: str, statement: str, when_to_apply: str = "",
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||||
force: bool = False,
|
tier: str = "always_on", system_ids: list[int] | None = None,
|
||||||
|
arose_from_id: int = 0, force: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new rule in a rulebook (a SHARED rule — keep it general).
|
"""Create a new rule in a rulebook (a SHARED rule — keep it general).
|
||||||
|
|
||||||
@@ -267,10 +296,36 @@ async def create_rule(
|
|||||||
Reusable code is a SNIPPET. Reach for a rule only when the thing genuinely
|
Reusable code is a SNIPPET. Reach for a rule only when the thing genuinely
|
||||||
is a standing instruction about how to work and nothing else can hold it.
|
is a standing instruction about how to work and nothing else can hold it.
|
||||||
|
|
||||||
|
ONE RULE = ONE THING YOU COULD VIOLATE. If a clause can be broken on its
|
||||||
|
own, and fixing that breakage doesn't require the neighbouring clauses, it
|
||||||
|
is a separate rule. Rules that FAIL TOGETHER get linked with relate_rules
|
||||||
|
(kind="co_surfaces"), never merged into one row: a merged rule cannot be
|
||||||
|
cited, surfaced or suppressed a clause at a time, and it grows without
|
||||||
|
limit because adding to it is always cheaper than adding a rule.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
topic_id: The topic to attach the rule to.
|
topic_id: The topic to attach the rule to.
|
||||||
title: A short imperative title (e.g. "dev is home").
|
title: A short imperative title (e.g. "dev is home").
|
||||||
statement: The actionable instruction (required). 1-2 sentences.
|
statement: The actionable instruction (required). 1-2 sentences.
|
||||||
|
when_to_apply: WHEN this rule fires — the trigger, not the
|
||||||
|
instruction. State the moment or the material: "before any git
|
||||||
|
push", "when adding a value to a CHECK-gated column", "when a
|
||||||
|
release is being cut". Write it even though the parameter is
|
||||||
|
optional: it decides the tier below, it is how the rule is found
|
||||||
|
when it matters, and a rule nobody can place is a rule nobody
|
||||||
|
applies.
|
||||||
|
tier: "always_on" (default) or "conditional".
|
||||||
|
The test: can you name the trigger WITHOUT naming a system, an
|
||||||
|
artifact type or a moment? If the honest answer is "whenever you
|
||||||
|
are working", it is always_on. If you had to name something, it is
|
||||||
|
conditional — and conditional costs nothing when it is irrelevant,
|
||||||
|
which is what lets it be as long as it needs to be.
|
||||||
|
system_ids: Ids from list_canonical_systems — the global AREAS this
|
||||||
|
rule is about. This is what lets a rule reach a project that is
|
||||||
|
working in that area, so a CI rule surfaces on a CI change.
|
||||||
|
arose_from_id: The note or task that CAUSED this rule (an incident, a
|
||||||
|
decision). Prefer this over naming the record inside `why`, which
|
||||||
|
cannot be followed and does not survive a rewording.
|
||||||
why: Optional rationale — the reason the rule exists.
|
why: Optional rationale — the reason the rule exists.
|
||||||
how_to_apply: Optional operationalization — when / where it kicks in.
|
how_to_apply: Optional operationalization — when / where it kicks in.
|
||||||
order_index: Display order within the topic (default 0).
|
order_index: Display order within the topic (default 0).
|
||||||
@@ -285,16 +340,18 @@ async def create_rule(
|
|||||||
return dedup_svc.duplicate_response(dup, "rule")
|
return dedup_svc.duplicate_response(dup, "rule")
|
||||||
rule = await rulebooks_svc.create_rule(
|
rule = await rulebooks_svc.create_rule(
|
||||||
topic_id=topic_id, user_id=uid,
|
topic_id=topic_id, user_id=uid,
|
||||||
title=title, statement=statement,
|
title=title, statement=statement, when_to_apply=when_to_apply,
|
||||||
|
tier=tier, arose_from_id=arose_from_id,
|
||||||
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
||||||
)
|
)
|
||||||
return rule.to_dict()
|
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
||||||
|
|
||||||
|
|
||||||
async def create_project_rule(
|
async def create_project_rule(
|
||||||
project_id: int, statement: str, title: str = "",
|
project_id: int, statement: str, title: str = "", when_to_apply: str = "",
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||||
force: bool = False,
|
tier: str = "always_on", system_ids: list[int] | None = None,
|
||||||
|
arose_from_id: int = 0, force: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a rule scoped to a single project (no rulebook needed).
|
"""Create a rule scoped to a single project (no rulebook needed).
|
||||||
|
|
||||||
@@ -306,11 +363,24 @@ async def create_project_rule(
|
|||||||
the rule is returned in get_project's applicable_rules (under
|
the rule is returned in get_project's applicable_rules (under
|
||||||
project_rules) and in list_rules(project_id=...).
|
project_rules) and in list_rules(project_id=...).
|
||||||
|
|
||||||
|
ONE RULE = ONE THING YOU COULD VIOLATE — see create_rule. A rule that
|
||||||
|
STRICTENS or REPLACES an inherited one is not a fresh rule: write it, then
|
||||||
|
relate_rules(kind="overrides") to the rule it supersedes, so the pair stays
|
||||||
|
connected instead of drifting into a contradiction nobody notices. A rule
|
||||||
|
that merely adds local detail to an inherited one uses "elaborates".
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
project_id: The project to attach the rule to.
|
project_id: The project to attach the rule to.
|
||||||
statement: The actionable instruction (required). 1-2 sentences.
|
statement: The actionable instruction (required). 1-2 sentences.
|
||||||
title: Short imperative title. If empty, derived from the first ~50
|
title: Short imperative title. If empty, derived from the first ~50
|
||||||
characters of statement.
|
characters of statement.
|
||||||
|
when_to_apply: WHEN this rule fires — the trigger, not the
|
||||||
|
instruction. See create_rule; it decides the tier and it is how
|
||||||
|
the rule is found at the moment it matters.
|
||||||
|
tier: "always_on" (default) or "conditional" — see create_rule.
|
||||||
|
system_ids: Ids from list_canonical_systems — the global AREAS this
|
||||||
|
rule is about.
|
||||||
|
arose_from_id: The note or task that CAUSED this rule.
|
||||||
why: Optional rationale — the reason the rule exists.
|
why: Optional rationale — the reason the rule exists.
|
||||||
how_to_apply: Optional operationalization — when / where it kicks in.
|
how_to_apply: Optional operationalization — when / where it kicks in.
|
||||||
order_index: Display order within the project's rule list (default 0).
|
order_index: Display order within the project's rule list (default 0).
|
||||||
@@ -326,23 +396,36 @@ async def create_project_rule(
|
|||||||
return dedup_svc.duplicate_response(dup, "rule")
|
return dedup_svc.duplicate_response(dup, "rule")
|
||||||
rule = await rulebooks_svc.create_project_rule(
|
rule = await rulebooks_svc.create_project_rule(
|
||||||
project_id=project_id, user_id=uid,
|
project_id=project_id, user_id=uid,
|
||||||
title=derived_title, statement=statement,
|
title=derived_title, statement=statement, when_to_apply=when_to_apply,
|
||||||
|
tier=tier, arose_from_id=arose_from_id,
|
||||||
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
why=why, how_to_apply=how_to_apply, order_index=order_index,
|
||||||
)
|
)
|
||||||
return rule.to_dict()
|
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
||||||
|
|
||||||
|
|
||||||
async def update_rule(
|
async def update_rule(
|
||||||
rule_id: int, title: str = "", statement: str = "",
|
rule_id: int, title: str = "", statement: str = "", when_to_apply: str = "",
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = -1,
|
why: str = "", how_to_apply: str = "", order_index: int = -1,
|
||||||
|
tier: str = "", system_ids: list[int] | None = None, arose_from_id: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Update a rule. Empty strings / order_index=-1 leave fields unchanged."""
|
"""Update a rule. Empty strings / order_index=-1 leave fields unchanged.
|
||||||
|
|
||||||
|
Adding `when_to_apply` and a `tier` to an existing rule is the ordinary way
|
||||||
|
a rule stops being preloaded into every session and starts arriving when it
|
||||||
|
is relevant. `system_ids` REPLACES the rule's areas (pass [] to clear).
|
||||||
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
fields: dict = {}
|
fields: dict = {}
|
||||||
if title:
|
if title:
|
||||||
fields["title"] = title
|
fields["title"] = title
|
||||||
if statement:
|
if statement:
|
||||||
fields["statement"] = statement
|
fields["statement"] = statement
|
||||||
|
if when_to_apply:
|
||||||
|
fields["when_to_apply"] = when_to_apply
|
||||||
|
if tier:
|
||||||
|
fields["tier"] = tier
|
||||||
|
if arose_from_id:
|
||||||
|
fields["arose_from_id"] = arose_from_id
|
||||||
if why:
|
if why:
|
||||||
fields["why"] = why
|
fields["why"] = why
|
||||||
if how_to_apply:
|
if how_to_apply:
|
||||||
@@ -352,7 +435,7 @@ async def update_rule(
|
|||||||
rule = await rulebooks_svc.update_rule(rule_id, uid, **fields)
|
rule = await rulebooks_svc.update_rule(rule_id, uid, **fields)
|
||||||
if rule is None:
|
if rule is None:
|
||||||
raise ValueError(f"rule {rule_id} not found")
|
raise ValueError(f"rule {rule_id} not found")
|
||||||
return rule.to_dict()
|
return await rulebooks_svc.rule_detail(uid, rule, system_ids)
|
||||||
|
|
||||||
|
|
||||||
async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
|
async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
|
||||||
@@ -407,6 +490,35 @@ async def unsubscribe_project_from_rulebook(
|
|||||||
|
|
||||||
# ── Suppressions — project-level mute of rulebook rules / topics ────────
|
# ── Suppressions — project-level mute of rulebook rules / topics ────────
|
||||||
|
|
||||||
|
async def exclude_always_on_rulebook(project_id: int, rulebook_id: int) -> dict:
|
||||||
|
"""Opt a project OUT of a whole always-on rulebook (milestone 297).
|
||||||
|
|
||||||
|
Always-on rulebooks bind every project implicitly; an inception decision
|
||||||
|
can say "not this one, not here". The exclusion is total for that project
|
||||||
|
— list_always_on_rules(project_id), enter_project/get_project rules and
|
||||||
|
the session-start context all leave it out and name it under
|
||||||
|
`excluded_always_on`. Owner-only; the rulebook must be always_on (a
|
||||||
|
subscribed rulebook is left with unsubscribe_project_from_rulebook).
|
||||||
|
Idempotent; include_always_on_rulebook reverses it. Normally reached via
|
||||||
|
decide_project_inception, not by hand.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
await rulebooks_svc.exclude_always_on_rulebook_for_project(
|
||||||
|
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
|
||||||
|
)
|
||||||
|
return {"project_id": project_id, "rulebook_id": rulebook_id, "excluded": True}
|
||||||
|
|
||||||
|
|
||||||
|
async def include_always_on_rulebook(project_id: int, rulebook_id: int) -> dict:
|
||||||
|
"""Reverse exclude_always_on_rulebook: the always-on rulebook binds this
|
||||||
|
project again. Idempotent."""
|
||||||
|
uid = current_user_id()
|
||||||
|
await rulebooks_svc.include_always_on_rulebook_for_project(
|
||||||
|
project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
|
||||||
|
)
|
||||||
|
return {"project_id": project_id, "rulebook_id": rulebook_id, "excluded": False}
|
||||||
|
|
||||||
|
|
||||||
async def suppress_rule_for_project(
|
async def suppress_rule_for_project(
|
||||||
project_id: int, rule_id: int,
|
project_id: int, rule_id: int,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -461,14 +573,62 @@ async def unsuppress_topic_for_project(
|
|||||||
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
|
return {"project_id": project_id, "topic_id": topic_id, "suppressed": False}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def relate_rules(
|
||||||
|
from_rule_id: int, to_rule_id: int, kind: str, note: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""Draw a typed edge between two rules. Both must be yours.
|
||||||
|
|
||||||
|
Reach for this INSTEAD of merging or duplicating:
|
||||||
|
|
||||||
|
- kind="co_surfaces" — these two fail together, so they must arrive
|
||||||
|
together. Use it when you are tempted to fold one rule into another
|
||||||
|
because "either could surface without the other": that instinct is
|
||||||
|
right and merging is the wrong fix, because a merged rule cannot be
|
||||||
|
cited, suppressed or surfaced a clause at a time. Symmetric — draw it
|
||||||
|
once, it reads from both ends.
|
||||||
|
- kind="overrides" — this rule supersedes that one for its scope. Use it
|
||||||
|
when a project rule is stricter than, or replaces, an inherited one,
|
||||||
|
instead of writing a near-copy that will drift from its parent.
|
||||||
|
- kind="elaborates" — this rule adds local specifics to that one, and
|
||||||
|
should arrive with it rather than instead of it.
|
||||||
|
|
||||||
|
Idempotent: re-drawing an existing edge returns it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
note: WHY the edge holds. Worth writing for the same reason a rule
|
||||||
|
carries `why` — a later reader deciding whether it still applies
|
||||||
|
needs the reasoning, not just the fact.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
relation = await rulebooks_svc.add_rule_relation(
|
||||||
|
uid, from_rule_id, to_rule_id, kind, note,
|
||||||
|
)
|
||||||
|
if relation is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"rule {from_rule_id} or {to_rule_id} not found (both must be yours)"
|
||||||
|
)
|
||||||
|
return relation.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
async def unrelate_rules(relation_id: int) -> dict:
|
||||||
|
"""Remove one edge between rules (from relate_rules / get_rule.relations)."""
|
||||||
|
uid = current_user_id()
|
||||||
|
if not await rulebooks_svc.remove_rule_relation(uid, relation_id):
|
||||||
|
raise ValueError(f"relation {relation_id} not found")
|
||||||
|
return {"deleted": relation_id}
|
||||||
|
|
||||||
def register(mcp) -> None:
|
def register(mcp) -> None:
|
||||||
for fn in (
|
for fn in (
|
||||||
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
|
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
|
||||||
list_topics, create_topic, update_topic, delete_topic,
|
list_topics, create_topic, update_topic, delete_topic,
|
||||||
list_rules, list_always_on_rules, get_rule,
|
list_rules, list_always_on_rules, get_rule,
|
||||||
create_rule, create_project_rule, update_rule, delete_rule,
|
create_rule, create_project_rule, update_rule, delete_rule,
|
||||||
|
relate_rules, unrelate_rules,
|
||||||
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
|
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
|
||||||
suppress_rule_for_project, unsuppress_rule_for_project,
|
suppress_rule_for_project, unsuppress_rule_for_project,
|
||||||
suppress_topic_for_project, unsuppress_topic_for_project,
|
suppress_topic_for_project, unsuppress_topic_for_project,
|
||||||
|
exclude_always_on_rulebook, include_always_on_rulebook,
|
||||||
):
|
):
|
||||||
mcp.tool(name=fn.__name__)(fn)
|
mcp.tool(name=fn.__name__)(fn)
|
||||||
|
|||||||
@@ -11,8 +11,42 @@ import time
|
|||||||
|
|
||||||
from scribe.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
from scribe.services.access import owner_names_for
|
from scribe.services.access import owner_names_for
|
||||||
from scribe.services.embeddings import DEFAULT_SIMILARITY_THRESHOLD, semantic_search_notes
|
from scribe.services.embeddings import (
|
||||||
from scribe.services.retrieval_telemetry import record_retrieval
|
DEFAULT_SIMILARITY_THRESHOLD, semantic_search_notes, semantic_search_rules,
|
||||||
|
)
|
||||||
|
from scribe.services.retrieval_telemetry import record_retrieval, retrieval_summary
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_rules(uid: int, q: str, limit: int) -> dict:
|
||||||
|
"""Rules by meaning — a separate result shape because a rule IS different.
|
||||||
|
|
||||||
|
A rule hit carries `why` and `how_to_apply`: they are the operational half
|
||||||
|
of a rule and the session-start payload never includes them, so a caller
|
||||||
|
who went looking should get the whole thing rather than a summary they then
|
||||||
|
have to re-fetch.
|
||||||
|
|
||||||
|
Rules are not project-scoped the way notes are (a family rule belongs to no
|
||||||
|
project), so `project_id` and `system_id` do not apply here.
|
||||||
|
"""
|
||||||
|
raw = await semantic_search_rules(uid, q, limit=limit)
|
||||||
|
return {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": rule.id,
|
||||||
|
"title": rule.title,
|
||||||
|
"statement": rule.statement,
|
||||||
|
"when_to_apply": rule.when_to_apply or "",
|
||||||
|
"tier": rule.tier,
|
||||||
|
"why": rule.why or "",
|
||||||
|
"how_to_apply": rule.how_to_apply or "",
|
||||||
|
"topic_id": rule.topic_id,
|
||||||
|
"project_id": rule.project_id,
|
||||||
|
"similarity": float(score),
|
||||||
|
}
|
||||||
|
for score, rule in raw
|
||||||
|
],
|
||||||
|
"total": len(raw),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def search(
|
async def search(
|
||||||
@@ -33,7 +67,13 @@ async def search(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
q: search query string.
|
q: search query string.
|
||||||
content_type: 'all' (default), 'note' (notes only), or 'task' (tasks only).
|
content_type: 'all' (default), 'note' (notes only), 'task' (tasks
|
||||||
|
only), or 'rule' (RULES only — the operator's standing
|
||||||
|
instructions, searchable by meaning since milestone 307).
|
||||||
|
Reach for 'rule' when you want to know whether a standing
|
||||||
|
instruction covers something: "is there a rule about release
|
||||||
|
tagging?". A hit carries the rule's `why` and `how_to_apply`,
|
||||||
|
which the session-start payload does not.
|
||||||
limit: maximum number of results (1-50).
|
limit: maximum number of results (1-50).
|
||||||
project_id: Scope results to one project. PASS THE ACTIVE PROJECT'S ID
|
project_id: Scope results to one project. PASS THE ACTIVE PROJECT'S ID
|
||||||
whenever a project is in scope (the one you entered with
|
whenever a project is in scope (the one you entered with
|
||||||
@@ -56,6 +96,8 @@ async def search(
|
|||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
limit = max(1, min(limit, 50))
|
limit = max(1, min(limit, 50))
|
||||||
|
if content_type == "rule":
|
||||||
|
return await _search_rules(uid, q, limit)
|
||||||
is_task = {"note": False, "task": True}.get(content_type) # None => any
|
is_task = {"note": False, "task": True}.get(content_type) # None => any
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
raw = await semantic_search_notes(
|
raw = await semantic_search_notes(
|
||||||
@@ -95,5 +137,52 @@ async def search(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def retrieval_telemetry(days: int = 30) -> dict:
|
||||||
|
"""What the retrieval telemetry says about YOUR surfaces, over a window.
|
||||||
|
|
||||||
|
The read half of the loop the ranker's thresholds are meant to be tuned
|
||||||
|
from (#2975). Reach for it before changing a similarity threshold, a top-k,
|
||||||
|
or deciding whether a reranker is worth building — the alternative is
|
||||||
|
hand-probing the live instance, which is how the last such decision had to
|
||||||
|
be made.
|
||||||
|
|
||||||
|
Two readouts, from the two tables built for them:
|
||||||
|
|
||||||
|
`sources` — per retrieval surface (`auto_inject`, `write_path`,
|
||||||
|
`mcp_search`, …), from `retrieval_logs`: `calls`, `zero_result_calls`,
|
||||||
|
`cleared_threshold` (how often the best hit beat the threshold in force for
|
||||||
|
that call), the `top_score` spread (p10/p50/p90/min/max), `avg_result_count`
|
||||||
|
and `p90_duration_ms`. THE number to read first is `cleared_threshold`
|
||||||
|
against `calls`, with the spread beside it: a surface that clears its bar
|
||||||
|
on nearly every call is either well-tuned or too loose, and p10 says which.
|
||||||
|
|
||||||
|
`usage` — from `note_usage_events`, at the per-note grain
|
||||||
|
`retrieval_logs` cannot be indexed at: `surfaced` (ranked surfacings — a
|
||||||
|
scored surface CHOSE the record), `ambient` (the rest), `pulled` split into
|
||||||
|
`pulled_by_agent` / `pulled_by_human`, the distinct-note counts, and
|
||||||
|
`pull_through`. That ratio is the corpus-side precision signal: records
|
||||||
|
surfaced often and opened never are dead weight competing for the injection
|
||||||
|
budget every turn.
|
||||||
|
|
||||||
|
`pull_through` is AGENT pulls over RANKED surfacings, and both halves of
|
||||||
|
that matter. "Is this record dead weight?" is answered by any pull; "was
|
||||||
|
that injected line useful?" — the question a threshold or a reranker is
|
||||||
|
tuned against — only by a pull the agent made. Aggregating across the
|
||||||
|
mcp_/rest_ prefix would silently answer the wrong one.
|
||||||
|
|
||||||
|
Scoped to your own telemetry — a retrieval log records what your agent
|
||||||
|
asked for, query text included, and is not a shared record kind.
|
||||||
|
|
||||||
|
`read_failed: true` means the query itself failed — deliberately distinct
|
||||||
|
from an empty window, because those two looked identical for weeks once
|
||||||
|
(#2663) and every counter silently read zero.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: window size, default 30. Clamped to at least 1.
|
||||||
|
"""
|
||||||
|
return await retrieval_summary(current_user_id(), days=days)
|
||||||
|
|
||||||
|
|
||||||
def register(mcp) -> None:
|
def register(mcp) -> None:
|
||||||
mcp.tool(name="search")(search)
|
mcp.tool(name="search")(search)
|
||||||
|
mcp.tool(name="retrieval_telemetry")(retrieval_telemetry)
|
||||||
|
|||||||
@@ -116,10 +116,18 @@ async def list_shapes(
|
|||||||
classify it: instance if it should use the canon, variant with
|
classify it: instance if it should use the canon, variant with
|
||||||
the why if deliberate); "recheck": judged instances/variants
|
the why if deliberate); "recheck": judged instances/variants
|
||||||
whose body changed since judged (the judgment stands; confirm
|
whose body changed since judged (the judgment stands; confirm
|
||||||
it again with classify_shapes, or re-judge).
|
it again with classify_shapes, or re-judge); "unused-css"
|
||||||
|
(milestone 302): live css rules no file's markup names — a
|
||||||
|
deletion candidate to look at, never auto-deleted. Transition
|
||||||
|
classes and concatenated names are read (#2970), so the list is
|
||||||
|
worth acting on; a name assembled in a script still is not.
|
||||||
|
|
||||||
Returns {"shapes": [...], "total": N} — total counts every match, not
|
Returns {"shapes": [...], "total": N} — total counts every match, not
|
||||||
just this page. Each row's `classified_by` says who judged: agent /
|
just this page. Every css row carries `used_by` {count, paths} — the
|
||||||
|
files whose markup names its class (milestone 302, the CSS consumer
|
||||||
|
map: a scoped rule is used by its own template; a shared recipe by
|
||||||
|
many; a count of 0 is "no template names it"). Each row's
|
||||||
|
`classified_by` says who judged: agent /
|
||||||
audit / import are judgments; `mechanical` is the canonical stamp the
|
audit / import are judgments; `mechanical` is the canonical stamp the
|
||||||
sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled
|
sync applies; `hook` is write-path EVIDENCE (#2791) — the session pulled
|
||||||
a snippet and then wrote code referencing/resembling it, so the shape
|
a snippet and then wrote code referencing/resembling it, so the shape
|
||||||
@@ -145,10 +153,12 @@ async def list_shapes(
|
|||||||
include_vanished=include_vanished, limit=limit, offset=offset,
|
include_vanished=include_vanished, limit=limit, offset=offset,
|
||||||
proposal=proposal, flag=flag, uses=uses,
|
proposal=proposal, flag=flag, uses=uses,
|
||||||
)
|
)
|
||||||
return {
|
shapes = [r.to_compact() if compact else r.to_dict() for r in rows]
|
||||||
"shapes": [r.to_compact() if compact else r.to_dict() for r in rows],
|
used_by = await shape_ledger_svc.used_by_map(rows)
|
||||||
"total": total,
|
for row, out in zip(rows, shapes):
|
||||||
}
|
if row.id in used_by:
|
||||||
|
out["used_by"] = used_by[row.id]
|
||||||
|
return {"shapes": shapes, "total": total}
|
||||||
|
|
||||||
|
|
||||||
async def classify_shapes_by_rule(
|
async def classify_shapes_by_rule(
|
||||||
@@ -295,7 +305,13 @@ async def refresh_pattern_coverage(project_id: int) -> dict:
|
|||||||
Returns the accounting payload — total, accounted, counts by status,
|
Returns the accounting payload — total, accounted, counts by status,
|
||||||
unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting
|
unclassified, repos, largest_gaps, `proposed` (canon proposals awaiting
|
||||||
confirmation), `derive_groups` (the biggest repeats-with-no-canon
|
confirmation), `derive_groups` (the biggest repeats-with-no-canon
|
||||||
families), `proposer` (what this refresh examined) — plus
|
families, each css one with `consumers` — the files whose markup
|
||||||
|
render it, milestone 302), `unused_css` (css rules no template names —
|
||||||
|
counting a `<Transition name=>`'s generated classes and concatenated
|
||||||
|
names as named, #2970; None where the map has no evidence of templates), `derive_new` (copies
|
||||||
|
that joined a family since the previous
|
||||||
|
refresh — the drift to act on now: derive the canon, don't queue an
|
||||||
|
audit), `proposer` (what this refresh examined) — plus
|
||||||
`pattern_coverage`, the same one-line summary enter_project carries.
|
`pattern_coverage`, the same one-line summary enter_project carries.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ from scribe.services import systems as systems_svc
|
|||||||
|
|
||||||
|
|
||||||
async def list_snippets(
|
async def list_snippets(
|
||||||
q: str = "", tag: str = "", limit: int = 50, project_id: int = 0,
|
q: str = "", tag: str = "", limit: int = 50, offset: int = 0,
|
||||||
|
project_id: int = 0,
|
||||||
repo: str = "", path: str = "", symbol: str = "", verification: str = "",
|
repo: str = "", path: str = "", symbol: str = "", verification: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""List recorded snippets — the project's pattern library.
|
"""List recorded snippets — the project's pattern library.
|
||||||
@@ -41,6 +42,9 @@ async def list_snippets(
|
|||||||
well as wording, so describe what you need the code to DO.
|
well as wording, so describe what you need the code to DO.
|
||||||
tag: Filter to a single tag, e.g. a language like "python" (optional).
|
tag: Filter to a single tag, e.g. a language like "python" (optional).
|
||||||
limit: Max results (1-100).
|
limit: Max results (1-100).
|
||||||
|
offset: Skip this many before returning — page through a corpus
|
||||||
|
larger than one call. `total` is the unpaged count, so
|
||||||
|
offset+limit against it says whether more remains.
|
||||||
project_id: Narrow to one project. 0 (default) searches every project —
|
project_id: Narrow to one project. 0 (default) searches every project —
|
||||||
usually what you want, since a helper you need here may well have
|
usually what you want, since a helper you need here may well have
|
||||||
been written somewhere else.
|
been written somewhere else.
|
||||||
@@ -81,6 +85,7 @@ async def list_snippets(
|
|||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
items, total = await snippets_svc.list_snippets(
|
items, total = await snippets_svc.list_snippets(
|
||||||
uid, q=q or None, tag=tag, limit=max(1, min(limit, 100)),
|
uid, q=q or None, tag=tag, limit=max(1, min(limit, 100)),
|
||||||
|
offset=max(0, offset),
|
||||||
project_id=project_id or None,
|
project_id=project_id or None,
|
||||||
repo=repo, path=path, symbol=symbol, verification=verification,
|
repo=repo, path=path, symbol=symbol, verification=verification,
|
||||||
)
|
)
|
||||||
@@ -207,6 +212,13 @@ async def get_snippet(snippet_id: int) -> dict:
|
|||||||
the source moved on — trust the location over the cached body and
|
the source moved on — trust the location over the cached body and
|
||||||
consider verify_snippet after you look.
|
consider verify_snippet after you look.
|
||||||
|
|
||||||
|
A record kept VERBATIM is confirmed by containment. A deliberately
|
||||||
|
ANNOTATED one — commentary the source does not carry — cannot be, so it
|
||||||
|
reads "current" on the authority of a standing `ok` verdict stamped at
|
||||||
|
the very commit just fetched (#2782); `verification` in the same payload
|
||||||
|
shows that basis. Edit the record, or let the file move past that commit,
|
||||||
|
and it reads "diverged" again until someone re-runs verify_snippet.
|
||||||
|
|
||||||
When the shape ledger has judgments against this snippet, the response
|
When the shape ledger has judgments against this snippet, the response
|
||||||
carries `instances` (shapes classified as conforming to it — the
|
carries `instances` (shapes classified as conforming to it — the
|
||||||
structured consumer map) and/or `variants` (named departures, each with
|
structured consumer map) and/or `variants` (named departures, each with
|
||||||
|
|||||||
+124
-30
@@ -13,6 +13,7 @@ Sentinels (match the milestone/task tool conventions):
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from scribe.mcp._context import current_user_id
|
from scribe.mcp._context import current_user_id
|
||||||
|
from scribe.services import canonical_systems as canonical_systems_svc
|
||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
from scribe.services import systems as systems_svc
|
from scribe.services import systems as systems_svc
|
||||||
|
|
||||||
@@ -30,10 +31,9 @@ _BOOTSTRAP_TITLES = 6
|
|||||||
# design (rule #115): archetypes any codebase could have, never one
|
# design (rule #115): archetypes any codebase could have, never one
|
||||||
# install's subsystems. Mint freely beyond the list; the duplicate gate
|
# install's subsystems. Mint freely beyond the list; the duplicate gate
|
||||||
# guards sprawl.
|
# guards sprawl.
|
||||||
_STANDARD_SYSTEMS = (
|
# The standard vocabulary lives in the GLOBAL canonical catalog since
|
||||||
"CI & Release", "Auth & Access", "Data Model & Storage", "API Surface",
|
# milestone 307 — the inception seed mints it and this ask names it, one list
|
||||||
"UI & Design", "Import & Export", "Background Jobs", "Observability",
|
# for both, now a table so a rule can reference an area by id (note 3026).
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
|
async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
|
||||||
@@ -67,6 +67,17 @@ async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
|
|||||||
titles = "; ".join(
|
titles = "; ".join(
|
||||||
'"' + " ".join((n.title or "").split())[:70] + '"' for n in recent
|
'"' + " ".join((n.title or "").split())[:70] + '"' for n in recent
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
standard = ", ".join(name for name, _charter in await systems_svc.standard_systems())
|
||||||
|
except Exception:
|
||||||
|
standard = ""
|
||||||
|
# An install whose catalog is empty still gets a usable ask — the standard
|
||||||
|
# names are an aid, not the point of the question.
|
||||||
|
standard_line = (
|
||||||
|
"Where an area fits a standard name, use it verbatim so it means the "
|
||||||
|
f"same thing in every project: {standard}. Mint freely beyond that "
|
||||||
|
"list — the duplicate gate guards sprawl. "
|
||||||
|
) if standard else "The duplicate gate guards sprawl. "
|
||||||
return (
|
return (
|
||||||
f"This project has {total} records and NO Systems modelled — none of "
|
f"This project has {total} records and NO Systems modelled — none of "
|
||||||
"them can be tagged to an area, so recurring problem-spots stay "
|
"them can be tagged to an area, so recurring problem-spots stay "
|
||||||
@@ -74,10 +85,8 @@ async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None:
|
|||||||
"asking permission — creating Systems is your call, not an approval "
|
"asking permission — creating Systems is your call, not an approval "
|
||||||
f"flow. From the areas the records themselves name (recent: {titles}), "
|
f"flow. From the areas the records themselves name (recent: {titles}), "
|
||||||
"create_system 3-6 Systems, each with a one-paragraph charter, then "
|
"create_system 3-6 Systems, each with a one-paragraph charter, then "
|
||||||
"tag this record (system_ids=[...]). Where an area fits a standard "
|
f"tag this record (system_ids=[...]). {standard_line}"
|
||||||
f"name, use it verbatim so it means the same thing in every project: "
|
"This ask repeats until the first "
|
||||||
f"{', '.join(_STANDARD_SYSTEMS)}. Mint freely beyond that list — the "
|
|
||||||
"duplicate gate guards sprawl. This ask repeats until the first "
|
|
||||||
"System exists; answering it once retires it for every future record."
|
"System exists; answering it once retires it for every future record."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -188,36 +197,60 @@ async def create_system(
|
|||||||
normalized name already exists in this project (archived included), the
|
normalized name already exists in this project (archived included), the
|
||||||
call returns {"duplicate": true, "existing_id": ...} instead of creating —
|
call returns {"duplicate": true, "existing_id": ...} instead of creating —
|
||||||
tag records to that one, or update_system it if its charter needs work.
|
tag records to that one, or update_system it if its charter needs work.
|
||||||
|
|
||||||
|
Also mapped against the GLOBAL area catalog, so the same word means the
|
||||||
|
same thing in every project (milestone 307). A name that IS a catalog area
|
||||||
|
up to spelling ("CI and Release" vs "CI & Release") is mapped for you and
|
||||||
|
the response says so. A name that merely RESEMBLES one comes back with
|
||||||
|
`canonical_suggestion` — an offer, not a decision: apply it with
|
||||||
|
map_system_to_canonical if it really is that area, ignore it if this is a
|
||||||
|
project-specific area. Either way the System is created; the catalog never
|
||||||
|
blocks a name.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
norm = " ".join(name.split()).lower()
|
assessment = await systems_svc.assess_system_name(uid, project_id, name)
|
||||||
if norm:
|
duplicate = assessment["duplicate"]
|
||||||
try:
|
if duplicate:
|
||||||
existing = await systems_svc.list_systems(
|
return {
|
||||||
uid, project_id, include_archived=True
|
"duplicate": True,
|
||||||
)
|
"existing_id": duplicate["id"],
|
||||||
except Exception:
|
"message": (
|
||||||
existing = []
|
f"System '{duplicate['name']}' (#{duplicate['id']}) already "
|
||||||
for s in existing:
|
"covers this area in this project. Tag records to it with "
|
||||||
if " ".join(s.name.split()).lower() == norm:
|
"system_ids, or update_system it if the charter needs "
|
||||||
return {
|
"revising — a second System with the same name would split "
|
||||||
"duplicate": True,
|
"the area's records across two piles."
|
||||||
"existing_id": s.id,
|
),
|
||||||
"message": (
|
}
|
||||||
f"System '{s.name}' (#{s.id}) already covers this area "
|
# An exact match is mechanical, so it is applied; an overlap is a judgment
|
||||||
"in this project. Tag records to it with system_ids, "
|
# call, so it is only offered (see services/canonical_systems).
|
||||||
"or update_system it if the charter needs revising — "
|
canonical = assessment["canonical"]
|
||||||
"a second System with the same name would split the "
|
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
|
||||||
"area's records across two piles."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
system = await systems_svc.create_system(
|
system = await systems_svc.create_system(
|
||||||
uid, project_id=project_id, name=name,
|
uid, project_id=project_id, name=name,
|
||||||
description=description or None, color=color or None,
|
description=description or None, color=color or None,
|
||||||
|
canonical_id=applied,
|
||||||
)
|
)
|
||||||
if system is None:
|
if system is None:
|
||||||
raise ValueError(f"cannot create system in project {project_id} (no write access)")
|
raise ValueError(f"cannot create system in project {project_id} (no write access)")
|
||||||
return system.to_dict()
|
out = system.to_dict()
|
||||||
|
if applied:
|
||||||
|
out["canonical_note"] = (
|
||||||
|
f"Mapped to the global area '{canonical['name']}' — the same "
|
||||||
|
"spelling-insensitive name. Your System keeps the name you gave it."
|
||||||
|
)
|
||||||
|
elif canonical:
|
||||||
|
out["canonical_suggestion"] = {
|
||||||
|
**canonical,
|
||||||
|
"message": (
|
||||||
|
f"The global catalog has '{canonical['name']}', which may be "
|
||||||
|
f"this same area. If it is, map_system_to_canonical("
|
||||||
|
f"{system.id}, {canonical['id']}) so records and rules about "
|
||||||
|
"this area line up across projects. If this area is specific "
|
||||||
|
"to this project, ignore it — unmapped is a valid state."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def list_systems(project_id: int, include_archived: bool = False) -> dict:
|
async def list_systems(project_id: int, include_archived: bool = False) -> dict:
|
||||||
@@ -322,6 +355,64 @@ async def delete_system(system_id: int) -> dict:
|
|||||||
return {"message": f"System {system_id} deleted."}
|
return {"message": f"System {system_id} deleted."}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_canonical_systems() -> dict:
|
||||||
|
"""The GLOBAL vocabulary of area names, shared by every project.
|
||||||
|
|
||||||
|
These are the standard names to prefer when creating a System, so the same
|
||||||
|
word means the same thing in every project on the instance — and, from
|
||||||
|
milestone 307, the ids a cross-project record can point at. A project's own
|
||||||
|
System keeps whatever name the project calls the area; mapping it here is
|
||||||
|
an association, never a rename.
|
||||||
|
|
||||||
|
Reach for it before create_system when the area is an ordinary one (CI,
|
||||||
|
auth, storage, the API, the UI), and pass the matching `canonical_id`.
|
||||||
|
"""
|
||||||
|
entries = await canonical_systems_svc.list_canonical_systems()
|
||||||
|
return {"canonical_systems": [e.to_dict() for e in entries]}
|
||||||
|
|
||||||
|
|
||||||
|
async def propose_canonical_mappings(project_id: int) -> dict:
|
||||||
|
"""Suggest a global area for each of this project's UNMAPPED Systems.
|
||||||
|
|
||||||
|
Returns PROPOSALS ONLY — nothing is written. Confirm the ones that are
|
||||||
|
right with map_system_to_canonical(system_id, canonical_id); ignore the
|
||||||
|
rest. Each carries a `basis`:
|
||||||
|
|
||||||
|
- `exact` — the names reduce to the same match key ("CI and Release" vs
|
||||||
|
"CI & Release"). Safe to confirm without much thought.
|
||||||
|
- `overlap` — they share a meaningful word ("CI & runners" vs "CI &
|
||||||
|
Release"). A judgment call: confirm only if they really are the same
|
||||||
|
area, since a wrong mapping surfaces cross-project records in the wrong
|
||||||
|
place.
|
||||||
|
|
||||||
|
A System with no proposal is not a problem — unmapped is a valid resting
|
||||||
|
state, and a genuinely project-specific area should stay that way.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
return {"proposals": await canonical_systems_svc.propose_mappings(uid, project_id)}
|
||||||
|
|
||||||
|
|
||||||
|
async def map_system_to_canonical(system_id: int, canonical_id: int = 0) -> dict:
|
||||||
|
"""Map one of a project's Systems onto a global area (or clear it).
|
||||||
|
|
||||||
|
Sets `canonical_id` and NOTHING else — the System's name, charter and every
|
||||||
|
record tagged to it are untouched. Pass canonical_id=0 to unmap.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
canonical_id: id from list_canonical_systems; 0 clears the mapping.
|
||||||
|
"""
|
||||||
|
uid = current_user_id()
|
||||||
|
system = await canonical_systems_svc.set_system_canonical(
|
||||||
|
uid, system_id, canonical_id or None,
|
||||||
|
)
|
||||||
|
if system is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"system {system_id} not found, no write access, "
|
||||||
|
f"or canonical_id {canonical_id} is not a live catalog entry"
|
||||||
|
)
|
||||||
|
return system.to_dict()
|
||||||
|
|
||||||
|
|
||||||
def register(mcp) -> None:
|
def register(mcp) -> None:
|
||||||
for fn in (
|
for fn in (
|
||||||
create_system,
|
create_system,
|
||||||
@@ -330,5 +421,8 @@ def register(mcp) -> None:
|
|||||||
update_system,
|
update_system,
|
||||||
list_system_records,
|
list_system_records,
|
||||||
delete_system,
|
delete_system,
|
||||||
|
list_canonical_systems,
|
||||||
|
propose_canonical_mappings,
|
||||||
|
map_system_to_canonical,
|
||||||
):
|
):
|
||||||
mcp.tool(name=fn.__name__)(fn)
|
mcp.tool(name=fn.__name__)(fn)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from scribe.models.user import User # noqa: E402, F401
|
|||||||
from scribe.models.app_log import AppLog # noqa: E402, F401
|
from scribe.models.app_log import AppLog # noqa: E402, F401
|
||||||
from scribe.models.password_reset import PasswordResetToken # noqa: E402, F401
|
from scribe.models.password_reset import PasswordResetToken # noqa: E402, F401
|
||||||
from scribe.models.invitation import InvitationToken # noqa: E402, F401
|
from scribe.models.invitation import InvitationToken # noqa: E402, F401
|
||||||
from scribe.models.embedding import NoteEmbedding # noqa: E402, F401
|
from scribe.models.embedding import NoteEmbedding, RuleEmbedding # noqa: E402, F401
|
||||||
from scribe.models.retrieval_log import RetrievalLog # noqa: E402, F401
|
from scribe.models.retrieval_log import RetrievalLog # noqa: E402, F401
|
||||||
from scribe.models.note_usage import NoteUsageEvent # noqa: E402, F401
|
from scribe.models.note_usage import NoteUsageEvent # noqa: E402, F401
|
||||||
from scribe.models.project import Project # noqa: E402, F401
|
from scribe.models.project import Project # noqa: E402, F401
|
||||||
@@ -39,11 +39,14 @@ from scribe.models.share import NoteShare, ProjectShare # noqa: E402, F401
|
|||||||
from scribe.models.notification import Notification # noqa: E402, F401
|
from scribe.models.notification import Notification # noqa: E402, F401
|
||||||
from scribe.models.api_key import ApiKey # noqa: E402, F401
|
from scribe.models.api_key import ApiKey # noqa: E402, F401
|
||||||
from scribe.models.user_profile import UserProfile # noqa: E402, F401
|
from scribe.models.user_profile import UserProfile # noqa: E402, F401
|
||||||
|
# Imported before rulebook: rule_systems foreign-keys canonical_systems.
|
||||||
|
from scribe.models.canonical_system import CanonicalSystem # noqa: E402, F401
|
||||||
from scribe.models.rulebook import ( # noqa: E402, F401
|
from scribe.models.rulebook import ( # noqa: E402, F401
|
||||||
Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions,
|
Rulebook, RulebookTopic, Rule, RuleRelation, project_rulebook_subscriptions,
|
||||||
|
rule_systems,
|
||||||
)
|
)
|
||||||
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
|
from scribe.models.repo_binding import RepoBinding # noqa: E402, F401
|
||||||
from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401
|
from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401
|
||||||
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse # noqa: E402, F401
|
from scribe.models.code_shape import CodeShape, CodeShapeConsumer, CodeShapeEvent, CodeShapeUse # noqa: E402, F401
|
||||||
from scribe.models.system import System, RecordSystem # noqa: E402, F401
|
from scribe.models.system import System, RecordSystem # noqa: E402, F401
|
||||||
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
|
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from sqlalchemy import Index, Integer, Text, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from scribe.models import Base
|
||||||
|
from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
|
||||||
|
|
||||||
|
|
||||||
|
class CanonicalSystem(Base, TimestampMixin, SoftDeleteMixin):
|
||||||
|
"""A GLOBAL area name — the shared vocabulary every project's Systems
|
||||||
|
can point at (milestone 307, decision note 3026).
|
||||||
|
|
||||||
|
A `System` is per-project and NOT NULL on project_id, so nothing outside a
|
||||||
|
project can reference one: a rule that applies across projects has no way
|
||||||
|
to say "this is about CI" without chaining itself to one project's row.
|
||||||
|
This table is that join key. It carries no `user_id` on purpose — a shared
|
||||||
|
project must INHERIT the vocabulary rather than re-earn it, so the catalog
|
||||||
|
is global and the same word means the same thing in every install.
|
||||||
|
|
||||||
|
It is a convergence aid, never a gate: `systems.canonical_id` is nullable,
|
||||||
|
an unmapped System stays fully usable, and `record_systems` never sees this
|
||||||
|
table at all — the local name is a legitimate local label and is never
|
||||||
|
rewritten to match.
|
||||||
|
|
||||||
|
`slug` is the match key, not a display value. It folds the spelling
|
||||||
|
differences that produced four names for one area on the author's own
|
||||||
|
instance ("CI & Release" / "CI and Release" / "CI & release"): an exact slug
|
||||||
|
hit maps automatically, and anything short of that becomes a proposal for a
|
||||||
|
human to confirm. See services/canonical_systems.canonical_slug.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "canonical_systems"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
# Normalized match key — unique among LIVE rows, so a soft-deleted entry
|
||||||
|
# doesn't block recreating the same area (the partial-unique convention
|
||||||
|
# rules/topics already use).
|
||||||
|
slug: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"uq_canonical_systems_slug", "slug",
|
||||||
|
unique=True, postgresql_where=text("deleted_at IS NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"slug": self.slug,
|
||||||
|
"description": self.description,
|
||||||
|
"order_index": self.order_index,
|
||||||
|
"created_at": iso(self.created_at),
|
||||||
|
"updated_at": iso(self.updated_at),
|
||||||
|
}
|
||||||
@@ -265,6 +265,52 @@ class CodeShapeUse(Base):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# How a consumer edge was established (milestone 302). `template` is the
|
||||||
|
# sync's mechanical read of a file's markup (class= / :class= / className=);
|
||||||
|
# the vocabulary is a list so a later basis (a stylesheet `@apply`, a script's
|
||||||
|
# classList) has a name without a schema change.
|
||||||
|
CONSUMER_BASES = ("template",)
|
||||||
|
|
||||||
|
|
||||||
|
class CodeShapeConsumer(Base):
|
||||||
|
"""One consumer edge: CSS shape → the file whose markup names its class
|
||||||
|
(milestone 302; note 2917 — CSS is watched by name, by recipe, by token
|
||||||
|
and by WHAT USES IT). The analogue of CodeShapeUse for styling: `uses`
|
||||||
|
says what a shape calls, this says who renders a class. Rows, not prose,
|
||||||
|
so "is this recipe shared or scoped?" is a count, not a guess.
|
||||||
|
|
||||||
|
Mechanical and fully recomputable: every coverage sync rebuilds a repo's
|
||||||
|
edges from its archive, so the table is not backed up (see
|
||||||
|
services/backup._NOT_INCLUDED). Cascades with the shape.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "code_shape_consumers"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("shape_id", "path", name="uq_code_shape_consumers_shape_path"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
shape_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
count: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
basis: Mapped[str] = mapped_column(Text, nullable=False, default="template")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"shape_id": self.shape_id,
|
||||||
|
"path": self.path,
|
||||||
|
"count": self.count,
|
||||||
|
"basis": self.basis,
|
||||||
|
"created_at": iso(self.created_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# What a shape's history records (#2793). Not "appeared" — first_seen and
|
# What a shape's history records (#2793). Not "appeared" — first_seen and
|
||||||
# created_at already say that on the row; history is for what CHANGED:
|
# created_at already say that on the row; history is for what CHANGED:
|
||||||
SHAPE_EVENTS = ("classified", "vanished", "reappeared", "drifted")
|
SHAPE_EVENTS = ("classified", "vanished", "reappeared", "drifted")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from pgvector.sqlalchemy import Vector
|
from pgvector.sqlalchemy import Vector
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from scribe.models import Base
|
from scribe.models import Base
|
||||||
@@ -45,3 +45,49 @@ class NoteEmbedding(Base):
|
|||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
default=lambda: datetime.now(timezone.utc),
|
default=lambda: datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RuleEmbedding(Base):
|
||||||
|
"""One embedding vector per CHUNK of a rule (milestone 307, note 3026).
|
||||||
|
|
||||||
|
A SIBLING of NoteEmbedding rather than a generalisation of it, decided
|
||||||
|
deliberately:
|
||||||
|
|
||||||
|
- The embedding ROW could have been made polymorphic. The SEARCH could not.
|
||||||
|
`semantic_search_notes` is a long function of Note-specific scoping —
|
||||||
|
the visibility clause, the supersession penalty, note_type/task_kind and
|
||||||
|
system filters — and a rule shares none of it. Rules scope by rulebook
|
||||||
|
ownership and project applicability instead.
|
||||||
|
- Generalising the row while still needing two searches is the worst of
|
||||||
|
both: a polymorphic key with referential integrity to neither table, on
|
||||||
|
the path every session start runs, to share four columns.
|
||||||
|
- What is genuinely common is BEHAVIOUR, not storage — get_embedding,
|
||||||
|
chunk_document, embedding_text and CHUNKER_VERSION are already free
|
||||||
|
functions and are reused as-is. Sharing those is the DRY win; sharing
|
||||||
|
the table would have been the DRY costume.
|
||||||
|
|
||||||
|
No `user_id`: NoteEmbedding carries one and its own search deliberately
|
||||||
|
ignores it (scoping on the note instead, or shared records become
|
||||||
|
unreachable). Rather than repeat a column that exists to be ignored, a
|
||||||
|
rule's reach is resolved by joining the rule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "rule_embeddings"
|
||||||
|
|
||||||
|
rule_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("rules.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
chunk_index: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
embedding: Mapped[list] = mapped_column(Vector(EMBEDDING_DIM), nullable=False)
|
||||||
|
# Exactly what this vector encodes — inspectable when a ranking surprises.
|
||||||
|
# For a rule this is the trigger-first document, NOT the rule's `why`:
|
||||||
|
# `why` is dated incident narrative and would drag every rule toward one
|
||||||
|
# centroid (measured in note 2485).
|
||||||
|
chunk_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
chunker_version: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import enum
|
import enum
|
||||||
from sqlalchemy import BigInteger, ForeignKey, Integer, Text
|
from sqlalchemy import BigInteger, ForeignKey, Integer, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from scribe.models import Base
|
from scribe.models import Base
|
||||||
from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
|
from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
|
||||||
@@ -36,6 +37,14 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
BigInteger, ForeignKey("forge_connections.id", ondelete="SET NULL"),
|
BigInteger, ForeignKey("forge_connections.id", ondelete="SET NULL"),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
|
# The inception record (milestone 297): what this project was decided to
|
||||||
|
# inherit, when, and through which door — {decided_at, decided_by, via,
|
||||||
|
# choices: {exclude_always_on_rulebooks, subscribe_rulebooks,
|
||||||
|
# design_system_id, seed_systems}}. NULL means nobody has decided yet,
|
||||||
|
# and enter_project asks; the effects themselves live in the subscription
|
||||||
|
# / exclusion tables, design_system_id and the project's Systems — this is
|
||||||
|
# the WHY, kept so later surfaces can say it. See services/inception.py.
|
||||||
|
inception: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -48,6 +57,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
"color": self.color,
|
"color": self.color,
|
||||||
"design_system_id": self.design_system_id,
|
"design_system_id": self.design_system_id,
|
||||||
"forge_connection_id": self.forge_connection_id,
|
"forge_connection_id": self.forge_connection_id,
|
||||||
|
"inception": self.inception,
|
||||||
"created_at": iso(self.created_at),
|
"created_at": iso(self.created_at),
|
||||||
"updated_at": iso(self.updated_at),
|
"updated_at": iso(self.updated_at),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table, Text, text
|
from sqlalchemy import (
|
||||||
|
BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table,
|
||||||
|
Text, UniqueConstraint, text,
|
||||||
|
)
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from scribe.models import Base
|
from scribe.models import Base
|
||||||
from scribe.models.base import SoftDeleteMixin, TimestampMixin, iso
|
from scribe.models.base import CreatedAtMixin, SoftDeleteMixin, TimestampMixin, iso
|
||||||
|
|
||||||
|
|
||||||
class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
|
class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
|
||||||
@@ -90,8 +93,26 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
)
|
)
|
||||||
title: Mapped[str] = mapped_column(Text)
|
title: Mapped[str] = mapped_column(Text)
|
||||||
statement: Mapped[str] = mapped_column(Text)
|
statement: Mapped[str] = mapped_column(Text)
|
||||||
|
# WHEN this rule applies — the trigger, not the instruction. Required of
|
||||||
|
# new rules at the service layer and nullable here, because rules written
|
||||||
|
# before migration 0088 have none and a migration cannot invent one.
|
||||||
|
# It carries three jobs at once (note 3026): it is the tier test made
|
||||||
|
# concrete, the readable form of the canon tag, and the half of the
|
||||||
|
# document that makes a rule findable by meaning.
|
||||||
|
when_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
# always_on = preloaded into every session, as every rule is today.
|
||||||
|
# conditional = reachable, and surfaced when its trigger fires. The
|
||||||
|
# default preserves existing behaviour exactly: nothing stops binding
|
||||||
|
# because of an upgrade. CHECK ck_rules_tier (migration 0088, rule 36).
|
||||||
|
tier: Mapped[str] = mapped_column(Text, default="always_on", server_default="always_on")
|
||||||
why: Mapped[str | None] = mapped_column(Text, nullable=True)
|
why: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
# The record that caused this rule — the edge notes and tasks already
|
||||||
|
# have. Rule 46's `why` names note 2813 in prose; this is that link as a
|
||||||
|
# field, so it survives a rewording of the paragraph.
|
||||||
|
arose_from_id: Mapped[int | None] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
order_index: Mapped[int] = mapped_column(Integer, default=0)
|
order_index: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
@@ -101,14 +122,78 @@ class Rule(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
"project_id": self.project_id,
|
"project_id": self.project_id,
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"statement": self.statement,
|
"statement": self.statement,
|
||||||
|
"when_to_apply": self.when_to_apply or "",
|
||||||
|
"tier": self.tier,
|
||||||
"why": self.why or "",
|
"why": self.why or "",
|
||||||
"how_to_apply": self.how_to_apply or "",
|
"how_to_apply": self.how_to_apply or "",
|
||||||
|
"arose_from_id": self.arose_from_id,
|
||||||
"order_index": self.order_index,
|
"order_index": self.order_index,
|
||||||
"created_at": iso(self.created_at),
|
"created_at": iso(self.created_at),
|
||||||
"updated_at": iso(self.updated_at),
|
"updated_at": iso(self.updated_at),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Which global AREA a rule is about (milestone 307). 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. This is the edge three
|
||||||
|
# projects were drawing by hand, as rule text copied into a System's charter.
|
||||||
|
rule_systems = Table(
|
||||||
|
"rule_systems",
|
||||||
|
Base.metadata,
|
||||||
|
Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("canonical_id", Integer, ForeignKey("canonical_systems.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RuleRelation(Base, CreatedAtMixin):
|
||||||
|
"""A typed edge between two rules. Each kind exists because its ABSENCE
|
||||||
|
forced a workaround somewhere in the operator's rulebook (note 3026).
|
||||||
|
|
||||||
|
- ``co_surfaces`` — these fail together, so they must arrive together.
|
||||||
|
Without it, the only way to guarantee that was to merge them into one
|
||||||
|
row, which is what happened to rule 46: split into 144, folded back the
|
||||||
|
same day because "either rule could surface without the other."
|
||||||
|
Symmetric in meaning; stored once and read both ways.
|
||||||
|
- ``overrides`` — this rule supersedes that one for its scope. Only
|
||||||
|
*suppression* existed, so an override had to be written as a parallel
|
||||||
|
rule that then drifts from its parent.
|
||||||
|
- ``elaborates`` — this rule adds local specifics to that one; surfacing
|
||||||
|
the parent brings the addendum with it.
|
||||||
|
|
||||||
|
``note`` records WHY the edge was drawn, for the same reason a rule
|
||||||
|
carries `why`: a later reader deciding whether it still holds needs the
|
||||||
|
reasoning, not just the fact.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "rule_relations"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
|
from_rule_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
to_rule_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
# CHECK ck_rule_relations_kind (migration 0088, rule 36).
|
||||||
|
kind: Mapped[str] = mapped_column(Text)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("from_rule_id", "to_rule_id", "kind", name="uq_rule_relations_edge"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"from_rule_id": self.from_rule_id,
|
||||||
|
"to_rule_id": self.to_rule_id,
|
||||||
|
"kind": self.kind,
|
||||||
|
"note": self.note or "",
|
||||||
|
"created_at": iso(self.created_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# Pure many-to-many — no model class, just the join table.
|
# Pure many-to-many — no model class, just the join table.
|
||||||
project_rulebook_subscriptions = Table(
|
project_rulebook_subscriptions = Table(
|
||||||
"project_rulebook_subscriptions",
|
"project_rulebook_subscriptions",
|
||||||
@@ -129,6 +214,19 @@ project_rule_suppressions = Table(
|
|||||||
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# A project's opt-out of a whole ALWAYS-ON rulebook (milestone 297): the
|
||||||
|
# sibling of the two suppression tables below, one level up. Always-on
|
||||||
|
# rulebooks bind every project implicitly; an inception decision can exclude
|
||||||
|
# specific ones for this project, and get_applicable_rules /
|
||||||
|
# list_always_on_rules(project_id) skip them. FKs CASCADE like the others.
|
||||||
|
project_rulebook_exclusions = Table(
|
||||||
|
"project_rulebook_exclusions",
|
||||||
|
Base.metadata,
|
||||||
|
Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
|
||||||
|
)
|
||||||
|
|
||||||
project_topic_suppressions = Table(
|
project_topic_suppressions = Table(
|
||||||
"project_topic_suppressions",
|
"project_topic_suppressions",
|
||||||
Base.metadata,
|
Base.metadata,
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
Integer, ForeignKey("projects.id", ondelete="CASCADE")
|
Integer, ForeignKey("projects.id", ondelete="CASCADE")
|
||||||
)
|
)
|
||||||
name: Mapped[str] = mapped_column(Text, default="", server_default="")
|
name: Mapped[str] = mapped_column(Text, default="", server_default="")
|
||||||
|
# The GLOBAL area this local System is an instance of (milestone 307).
|
||||||
|
# Nullable and SET NULL on purpose: the catalog is a convergence aid, not a
|
||||||
|
# gate — an unmapped System is fully usable, and retiring a canonical entry
|
||||||
|
# must never take a project's System with it. The local `name` is NEVER
|
||||||
|
# rewritten to match the canonical one; this column is the join key, and
|
||||||
|
# the name stays whatever the project calls the area.
|
||||||
|
canonical_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("canonical_systems.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
color: Mapped[str | None] = mapped_column(Text, nullable=True)
|
color: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
# active | archived — systems accumulate; archive rather than delete.
|
# active | archived — systems accumulate; archive rather than delete.
|
||||||
@@ -40,6 +49,7 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
|
|||||||
"user_id": self.user_id,
|
"user_id": self.user_id,
|
||||||
"project_id": self.project_id,
|
"project_id": self.project_id,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
|
"canonical_id": self.canonical_id,
|
||||||
"description": self.description,
|
"description": self.description,
|
||||||
"color": self.color,
|
"color": self.color,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Canonical-system routes — the GLOBAL area vocabulary, and the mapping of a
|
||||||
|
project's Systems onto it (milestone 307, decision note 3026).
|
||||||
|
|
||||||
|
Two shapes live here because they are two halves of one idea:
|
||||||
|
|
||||||
|
- `/api/canonical-systems` — the catalog itself. Readable by any signed-in
|
||||||
|
user (it is shared vocabulary, not user data); writable only by an admin,
|
||||||
|
since a global list anyone can extend stops being a shared list.
|
||||||
|
- the mapping endpoints — authorised by the PROJECT, because mapping writes a
|
||||||
|
project's own System row. The service enforces both; these are thin wrappers.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from scribe.auth import admin_required, get_current_user_id, login_required
|
||||||
|
from scribe.routes.utils import not_found
|
||||||
|
from scribe.services import canonical_systems as canonical_svc
|
||||||
|
from scribe.services.projects import get_project_for_user
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
canonical_systems_bp = Blueprint("canonical_systems", __name__, url_prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@canonical_systems_bp.route("/canonical-systems", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
async def list_canonical_systems_route():
|
||||||
|
entries = await canonical_svc.list_canonical_systems()
|
||||||
|
return jsonify({"canonical_systems": [e.to_dict() for e in entries]})
|
||||||
|
|
||||||
|
|
||||||
|
@canonical_systems_bp.route("/canonical-systems", methods=["POST"])
|
||||||
|
@admin_required
|
||||||
|
async def create_canonical_system_route():
|
||||||
|
uid = get_current_user_id()
|
||||||
|
data = await request.get_json() or {}
|
||||||
|
if not (data.get("name") or "").strip():
|
||||||
|
return jsonify({"error": "name is required"}), 400
|
||||||
|
entry = await canonical_svc.create_canonical_system(
|
||||||
|
uid, data["name"], description=data.get("description"),
|
||||||
|
)
|
||||||
|
if entry is None:
|
||||||
|
return jsonify({"error": "Permission denied"}), 403
|
||||||
|
# The slug duplicate gate answers with the entry that already covers the
|
||||||
|
# area rather than minting a second spelling of it — 409, not a silent
|
||||||
|
# second row (the whole point of the table).
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
return jsonify(entry), 409
|
||||||
|
return jsonify(entry.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@canonical_systems_bp.route("/canonical-systems/<int:canonical_id>", methods=["PATCH"])
|
||||||
|
@admin_required
|
||||||
|
async def update_canonical_system_route(canonical_id: int):
|
||||||
|
uid = get_current_user_id()
|
||||||
|
data = await request.get_json() or {}
|
||||||
|
fields = {k: v for k, v in data.items() if k in ("name", "description", "order_index")}
|
||||||
|
entry = await canonical_svc.update_canonical_system(uid, canonical_id, **fields)
|
||||||
|
if entry is None:
|
||||||
|
return not_found("Canonical system")
|
||||||
|
return jsonify(entry.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@canonical_systems_bp.route(
|
||||||
|
"/projects/<int:project_id>/canonical-proposals", methods=["GET"]
|
||||||
|
)
|
||||||
|
@login_required
|
||||||
|
async def propose_canonical_mappings_route(project_id: int):
|
||||||
|
"""Proposals only — this endpoint writes nothing. The PUT below applies one."""
|
||||||
|
uid = get_current_user_id()
|
||||||
|
if await get_project_for_user(uid, project_id) is None:
|
||||||
|
return not_found("Project")
|
||||||
|
return jsonify({"proposals": await canonical_svc.propose_mappings(uid, project_id)})
|
||||||
|
|
||||||
|
|
||||||
|
@canonical_systems_bp.route("/systems/<int:system_id>/canonical", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
async def map_system_to_canonical_route(system_id: int):
|
||||||
|
"""Map or unmap one System. Body: {"canonical_id": <id>|null}.
|
||||||
|
|
||||||
|
Sets that column and nothing else — no rename, no change to which records
|
||||||
|
are tagged to the System.
|
||||||
|
"""
|
||||||
|
uid = get_current_user_id()
|
||||||
|
data = await request.get_json() or {}
|
||||||
|
canonical_id = data.get("canonical_id")
|
||||||
|
if canonical_id is not None and not isinstance(canonical_id, int):
|
||||||
|
return jsonify({"error": "canonical_id must be an integer or null"}), 400
|
||||||
|
system = await canonical_svc.set_system_canonical(uid, system_id, canonical_id)
|
||||||
|
if system is None:
|
||||||
|
return not_found("System or canonical system")
|
||||||
|
return jsonify(system.to_dict())
|
||||||
@@ -129,6 +129,15 @@ async def write_path_prior_art():
|
|||||||
surfaced. A separate channel on purpose: a reuse
|
surfaced. A separate channel on purpose: a reuse
|
||||||
hint shown early must not suppress the record-sync
|
hint shown early must not suppress the record-sync
|
||||||
nudge when the recorded file is edited later.
|
nudge when the recorded file is edited later.
|
||||||
|
exclude_rule_ids (opt) — comma-separated RULE ids already surfaced
|
||||||
|
this session. Its own channel like the three
|
||||||
|
above, and for the same reason: a rule named
|
||||||
|
twenty turns ago should not be re-offered on
|
||||||
|
every subsequent write.
|
||||||
|
exclude_derive (opt) — comma-separated derive keys (a derive group id
|
||||||
|
or `canon:<snippet_id>`) already named this
|
||||||
|
session by the ledger arm (#2900); its own
|
||||||
|
channel, like the two above.
|
||||||
shapes (opt) — comma-separated `kind:name` definitions the hook
|
shapes (opt) — comma-separated `kind:name` definitions the hook
|
||||||
found in (or enclosing) the payload, kind being
|
found in (or enclosing) the payload, kind being
|
||||||
css|sym. The shape ledger's write-path feed
|
css|sym. The shape ledger's write-path feed
|
||||||
@@ -144,6 +153,10 @@ async def write_path_prior_art():
|
|||||||
project_id, repo, _unbound = await _project_scope()
|
project_id, repo, _unbound = await _project_scope()
|
||||||
exclude_ids = _int_list(request.args.get("exclude_ids"))
|
exclude_ids = _int_list(request.args.get("exclude_ids"))
|
||||||
exclude_sync_ids = _int_list(request.args.get("exclude_sync_ids"))
|
exclude_sync_ids = _int_list(request.args.get("exclude_sync_ids"))
|
||||||
|
exclude_derive = [
|
||||||
|
p.strip() for p in (request.args.get("exclude_derive") or "").split(",") if p.strip()
|
||||||
|
]
|
||||||
|
exclude_rule_ids = _int_list(request.args.get("exclude_rule_ids"))
|
||||||
shapes = _parse_shapes(request.args.get("shapes") or "")
|
shapes = _parse_shapes(request.args.get("shapes") or "")
|
||||||
api_key = getattr(g, "api_key", None)
|
api_key = getattr(g, "api_key", None)
|
||||||
may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
|
may_stamp = api_key is None or getattr(api_key, "scope", "") == "write"
|
||||||
@@ -153,6 +166,8 @@ async def write_path_prior_art():
|
|||||||
exclude_ids=exclude_ids, exclude_sync_ids=exclude_sync_ids,
|
exclude_ids=exclude_ids, exclude_sync_ids=exclude_sync_ids,
|
||||||
stamp_shapes=shapes if may_stamp else None,
|
stamp_shapes=shapes if may_stamp else None,
|
||||||
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
|
repo_key=repo_bindings_svc.normalize_repo_key(repo) if repo else "",
|
||||||
|
exclude_derive=exclude_derive,
|
||||||
|
exclude_rule_ids=exclude_rule_ids,
|
||||||
)
|
)
|
||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from quart import Blueprint, g, jsonify, request
|
|||||||
|
|
||||||
from scribe.auth import login_required, get_current_user_id
|
from scribe.auth import login_required, get_current_user_id
|
||||||
from scribe.routes.utils import not_found, parse_pagination
|
from scribe.routes.utils import not_found, parse_pagination
|
||||||
|
from scribe.services import inception as inception_svc
|
||||||
from scribe.services.milestones import list_milestones
|
from scribe.services.milestones import list_milestones
|
||||||
from scribe.services.notes import list_notes
|
from scribe.services.notes import list_notes
|
||||||
from scribe.services.projects import (
|
from scribe.services.projects import (
|
||||||
@@ -66,6 +67,15 @@ async def create_project_route():
|
|||||||
status = data.get("status", "active")
|
status = data.get("status", "active")
|
||||||
if status not in ("active", "paused", "completed", "archived"):
|
if status not in ("active", "paused", "completed", "archived"):
|
||||||
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
|
return jsonify({"error": "status must be 'active', 'paused', 'completed', or 'archived'"}), 400
|
||||||
|
# The inception decision rides the create (milestone 297): the UI's
|
||||||
|
# second step sends `inception: {choices}`; absent = undecided, and the
|
||||||
|
# project page shows the card until it is. Validated before the create
|
||||||
|
# so a bad decision never leaves a half-made project behind.
|
||||||
|
inception = data.get("inception")
|
||||||
|
if inception is not None:
|
||||||
|
error = inception_svc.validate_inception(inception)
|
||||||
|
if error:
|
||||||
|
return jsonify({"error": error}), 400
|
||||||
project = await create_project(
|
project = await create_project(
|
||||||
uid,
|
uid,
|
||||||
title=data["title"],
|
title=data["title"],
|
||||||
@@ -74,7 +84,44 @@ async def create_project_route():
|
|||||||
color=data.get("color"),
|
color=data.get("color"),
|
||||||
status=status,
|
status=status,
|
||||||
)
|
)
|
||||||
return jsonify(project.to_dict()), 201
|
out = project.to_dict()
|
||||||
|
if inception is not None:
|
||||||
|
try:
|
||||||
|
decided = await inception_svc.decide(uid, project.id, choices=inception, via="ui")
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc), "project": out}), 400
|
||||||
|
out["inception"] = decided["inception"]
|
||||||
|
out["inception_effects"] = decided["effects"]
|
||||||
|
return jsonify(out), 201
|
||||||
|
|
||||||
|
|
||||||
|
@projects_bp.route("/<int:project_id>/inception", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
async def decide_inception_route(project_id: int):
|
||||||
|
"""Record (or re-record) what a project inherits — milestone 297.
|
||||||
|
Body: the choices object {exclude_always_on_rulebooks, subscribe_rulebooks,
|
||||||
|
design_system_id, seed_systems}; owner-only."""
|
||||||
|
uid = get_current_user_id()
|
||||||
|
data = await request.get_json() or {}
|
||||||
|
choices = data.get("choices", data)
|
||||||
|
try:
|
||||||
|
decided = await inception_svc.decide(uid, project_id, choices=choices, via="ui")
|
||||||
|
except ValueError as exc:
|
||||||
|
msg = str(exc)
|
||||||
|
status = 404 if "not found" in msg else 400
|
||||||
|
return jsonify({"error": msg}), status
|
||||||
|
return jsonify({"project_id": project_id, **decided})
|
||||||
|
|
||||||
|
|
||||||
|
@projects_bp.route("/<int:project_id>/inception/defaults", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
async def inception_defaults_route(project_id: int):
|
||||||
|
"""What the project inherits if nobody decides — the card's payload."""
|
||||||
|
uid = get_current_user_id()
|
||||||
|
try:
|
||||||
|
return jsonify(await inception_svc.current_defaults(uid, project_id))
|
||||||
|
except ValueError:
|
||||||
|
return not_found("Project")
|
||||||
|
|
||||||
|
|
||||||
@projects_bp.route("/<int:project_id>", methods=["GET"])
|
@projects_bp.route("/<int:project_id>", methods=["GET"])
|
||||||
|
|||||||
@@ -162,33 +162,73 @@ async def create_rule(topic_id: int):
|
|||||||
why=data.get("why", ""),
|
why=data.get("why", ""),
|
||||||
how_to_apply=data.get("how_to_apply", ""),
|
how_to_apply=data.get("how_to_apply", ""),
|
||||||
order_index=data.get("order_index", 0),
|
order_index=data.get("order_index", 0),
|
||||||
|
when_to_apply=data.get("when_to_apply", ""),
|
||||||
|
tier=data.get("tier", "always_on"),
|
||||||
|
arose_from_id=data.get("arose_from_id", 0) or 0,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 404
|
return jsonify({"error": str(exc)}), 404
|
||||||
return jsonify(rule.to_dict()), 201
|
return jsonify(await rulebooks_svc.rule_detail(
|
||||||
|
get_current_user_id(), rule, data.get("system_ids"),
|
||||||
|
)), 201
|
||||||
|
|
||||||
|
|
||||||
@rulebooks_bp.get("/rules/<int:rule_id>")
|
@rulebooks_bp.get("/rules/<int:rule_id>")
|
||||||
@login_required
|
@login_required
|
||||||
async def get_rule(rule_id: int):
|
async def get_rule(rule_id: int):
|
||||||
rule = await rulebooks_svc.get_rule(rule_id, get_current_user_id())
|
uid = get_current_user_id()
|
||||||
|
rule = await rulebooks_svc.get_rule(rule_id, uid)
|
||||||
if rule is None:
|
if rule is None:
|
||||||
return jsonify({"error": "rule not found"}), 404
|
return jsonify({"error": "rule not found"}), 404
|
||||||
return jsonify(rule.to_dict())
|
return jsonify(await rulebooks_svc.rule_detail(uid, rule))
|
||||||
|
|
||||||
|
|
||||||
@rulebooks_bp.patch("/rules/<int:rule_id>")
|
@rulebooks_bp.patch("/rules/<int:rule_id>")
|
||||||
@login_required
|
@login_required
|
||||||
async def update_rule(rule_id: int):
|
async def update_rule(rule_id: int):
|
||||||
data = await request.get_json() or {}
|
data = await request.get_json() or {}
|
||||||
|
uid = get_current_user_id()
|
||||||
fields = {
|
fields = {
|
||||||
k: v for k, v in data.items()
|
k: v for k, v in data.items()
|
||||||
if k in ("title", "statement", "why", "how_to_apply", "order_index")
|
if k in ("title", "statement", "why", "how_to_apply", "order_index",
|
||||||
|
"when_to_apply", "tier", "arose_from_id")
|
||||||
}
|
}
|
||||||
rule = await rulebooks_svc.update_rule(rule_id, get_current_user_id(), **fields)
|
rule = await rulebooks_svc.update_rule(rule_id, uid, **fields)
|
||||||
if rule is None:
|
if rule is None:
|
||||||
return jsonify({"error": "rule not found"}), 404
|
return jsonify({"error": "rule not found"}), 404
|
||||||
return jsonify(rule.to_dict())
|
return jsonify(await rulebooks_svc.rule_detail(uid, rule, data.get("system_ids")))
|
||||||
|
|
||||||
|
|
||||||
|
@rulebooks_bp.post("/rules/<int:rule_id>/relations")
|
||||||
|
@login_required
|
||||||
|
async def relate_rules(rule_id: int):
|
||||||
|
"""Draw a typed edge FROM this rule to another.
|
||||||
|
|
||||||
|
Body: {"to_rule_id": N, "kind": "co_surfaces"|"overrides"|"elaborates",
|
||||||
|
"note": "..."}. Idempotent — re-drawing an edge returns the existing one.
|
||||||
|
"""
|
||||||
|
data = await request.get_json() or {}
|
||||||
|
to_rule_id = data.get("to_rule_id")
|
||||||
|
if not isinstance(to_rule_id, int):
|
||||||
|
return jsonify({"error": "to_rule_id is required"}), 400
|
||||||
|
try:
|
||||||
|
relation = await rulebooks_svc.add_rule_relation(
|
||||||
|
get_current_user_id(), rule_id, to_rule_id,
|
||||||
|
data.get("kind", ""), data.get("note", ""),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
if relation is None:
|
||||||
|
return jsonify({"error": "rule not found"}), 404
|
||||||
|
return jsonify(relation.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@rulebooks_bp.delete("/rule-relations/<int:relation_id>")
|
||||||
|
@login_required
|
||||||
|
async def unrelate_rules(relation_id: int):
|
||||||
|
if not await rulebooks_svc.remove_rule_relation(get_current_user_id(), relation_id):
|
||||||
|
return jsonify({"error": "relation not found"}), 404
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
@rulebooks_bp.delete("/rules/<int:rule_id>")
|
@rulebooks_bp.delete("/rules/<int:rule_id>")
|
||||||
@@ -288,6 +328,32 @@ async def unsuppress_project_topic(project_id: int, topic_id: int):
|
|||||||
return "", 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@rulebooks_bp.post("/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>")
|
||||||
|
@login_required
|
||||||
|
async def exclude_project_rulebook(project_id: int, rulebook_id: int):
|
||||||
|
"""Opt the project out of a whole always-on rulebook (milestone 297)."""
|
||||||
|
try:
|
||||||
|
await rulebooks_svc.exclude_always_on_rulebook_for_project(
|
||||||
|
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
msg = str(exc)
|
||||||
|
return jsonify({"error": msg}), (400 if "not always-on" in msg else 404)
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@rulebooks_bp.delete("/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>")
|
||||||
|
@login_required
|
||||||
|
async def include_project_rulebook(project_id: int, rulebook_id: int):
|
||||||
|
try:
|
||||||
|
await rulebooks_svc.include_always_on_rulebook_for_project(
|
||||||
|
project_id=project_id, rulebook_id=rulebook_id, user_id=get_current_user_id(),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 404
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
@rulebooks_bp.post("/projects/<int:project_id>/rules")
|
@rulebooks_bp.post("/projects/<int:project_id>/rules")
|
||||||
@login_required
|
@login_required
|
||||||
async def create_project_rule(project_id: int):
|
async def create_project_rule(project_id: int):
|
||||||
@@ -306,7 +372,12 @@ async def create_project_rule(project_id: int):
|
|||||||
why=data.get("why", ""),
|
why=data.get("why", ""),
|
||||||
how_to_apply=data.get("how_to_apply", ""),
|
how_to_apply=data.get("how_to_apply", ""),
|
||||||
order_index=data.get("order_index", 0),
|
order_index=data.get("order_index", 0),
|
||||||
|
when_to_apply=data.get("when_to_apply", ""),
|
||||||
|
tier=data.get("tier", "always_on"),
|
||||||
|
arose_from_id=data.get("arose_from_id", 0) or 0,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return jsonify({"error": str(exc)}), 404
|
return jsonify({"error": str(exc)}), 404
|
||||||
return jsonify(rule.to_dict()), 201
|
return jsonify(await rulebooks_svc.rule_detail(
|
||||||
|
get_current_user_id(), rule, data.get("system_ids"),
|
||||||
|
)), 201
|
||||||
|
|||||||
@@ -62,14 +62,38 @@ async def create_system_route(project_id: int):
|
|||||||
data = await request.get_json() or {}
|
data = await request.get_json() or {}
|
||||||
if not (data.get("name") or "").strip():
|
if not (data.get("name") or "").strip():
|
||||||
return jsonify({"error": "name is required"}), 400
|
return jsonify({"error": "name is required"}), 400
|
||||||
|
# The same gate the MCP door enforces. It lived only in the tool layer
|
||||||
|
# until now, which is exactly how the web UI shipped without gates the
|
||||||
|
# agent surface had (#2482) — one service call, one answer (rule 33).
|
||||||
|
assessment = await systems_svc.assess_system_name(uid, project_id, data["name"])
|
||||||
|
duplicate = assessment["duplicate"]
|
||||||
|
if duplicate and not data.get("force"):
|
||||||
|
return jsonify({
|
||||||
|
"duplicate": True,
|
||||||
|
"existing_id": duplicate["id"],
|
||||||
|
"error": (
|
||||||
|
f"“{duplicate['name']}” already covers this area in this "
|
||||||
|
"project. Tag records to it, or rename it if its charter has "
|
||||||
|
"moved on — a second System with the same name splits the "
|
||||||
|
"area's records across two piles."
|
||||||
|
),
|
||||||
|
}), 409
|
||||||
|
canonical = assessment["canonical"]
|
||||||
|
# Exact is mechanical and applied; overlap is a judgment call and is only
|
||||||
|
# offered back for the form to present.
|
||||||
|
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
|
||||||
system = await systems_svc.create_system(
|
system = await systems_svc.create_system(
|
||||||
uid, project_id=project_id, name=data["name"],
|
uid, project_id=project_id, name=data["name"],
|
||||||
description=data.get("description"), color=data.get("color"),
|
description=data.get("description"), color=data.get("color"),
|
||||||
order_index=data.get("order_index", 0),
|
order_index=data.get("order_index", 0),
|
||||||
|
canonical_id=data.get("canonical_id") or applied,
|
||||||
)
|
)
|
||||||
if system is None:
|
if system is None:
|
||||||
return jsonify({"error": "Permission denied"}), 403
|
return jsonify({"error": "Permission denied"}), 403
|
||||||
return jsonify(system.to_dict()), 201
|
out = system.to_dict()
|
||||||
|
if canonical and canonical["basis"] == "overlap" and not system.canonical_id:
|
||||||
|
out["canonical_suggestion"] = canonical
|
||||||
|
return jsonify(out), 201
|
||||||
|
|
||||||
|
|
||||||
@systems_bp.route("/<int:project_id>/systems/<int:system_id>", methods=["GET"])
|
@systems_bp.route("/<int:project_id>/systems/<int:system_id>", methods=["GET"])
|
||||||
|
|||||||
+220
-11
@@ -11,6 +11,8 @@ from scribe.models.note_supersession import NoteSupersession
|
|||||||
from scribe.models.note_version import NoteVersion
|
from scribe.models.note_version import NoteVersion
|
||||||
from scribe.models.design_system import DesignSystem, DesignToken
|
from scribe.models.design_system import DesignSystem, DesignToken
|
||||||
from scribe.models.note_usage import NoteUsageEvent
|
from scribe.models.note_usage import NoteUsageEvent
|
||||||
|
from scribe.models.canonical_system import CanonicalSystem
|
||||||
|
from scribe.models.rulebook import RuleRelation, rule_systems as rule_systems_t
|
||||||
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
|
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
|
||||||
from scribe.models.project import Project
|
from scribe.models.project import Project
|
||||||
from scribe.models.repo_binding import RepoBinding
|
from scribe.models.repo_binding import RepoBinding
|
||||||
@@ -19,6 +21,7 @@ from scribe.models.rulebook import (
|
|||||||
Rulebook,
|
Rulebook,
|
||||||
RulebookTopic,
|
RulebookTopic,
|
||||||
project_rule_suppressions,
|
project_rule_suppressions,
|
||||||
|
project_rulebook_exclusions,
|
||||||
project_rulebook_subscriptions,
|
project_rulebook_subscriptions,
|
||||||
project_topic_suppressions,
|
project_topic_suppressions,
|
||||||
)
|
)
|
||||||
@@ -45,8 +48,10 @@ logger = logging.getLogger(__name__)
|
|||||||
# v9 (2026-08) added code_shape_uses — the ledger's consumption edges (#2870):
|
# v9 (2026-08) added code_shape_uses — the ledger's consumption edges (#2870):
|
||||||
# judgment-grade edges (agent/audit/import) are operator records; mechanical
|
# judgment-grade edges (agent/audit/import) are operator records; mechanical
|
||||||
# ones (reference/hook) travel too, cheaply, and the next refresh refreshes them.
|
# ones (reference/hook) travel too, cheaply, and the next refresh refreshes them.
|
||||||
|
# v10 (2026-08) added projects.inception + project_rulebook_exclusions
|
||||||
|
# (milestone 297): the WHY a project inherits what it does, and its opt-outs.
|
||||||
# Bump when the serialized schema changes.
|
# Bump when the serialized schema changes.
|
||||||
BACKUP_VERSION = 9
|
BACKUP_VERSION = 10
|
||||||
|
|
||||||
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
|
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
|
||||||
# below, these two lists must together account for the entire schema — which is
|
# below, these two lists must together account for the entire schema — which is
|
||||||
@@ -60,17 +65,24 @@ _BACKED_UP = [
|
|||||||
"users", "projects", "milestones", "notes", "task_logs", "note_drafts",
|
"users", "projects", "milestones", "notes", "task_logs", "note_drafts",
|
||||||
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
|
"note_versions", "settings", "rulebooks", "rulebook_topics", "rules",
|
||||||
"project_rulebook_subscriptions", "project_rule_suppressions",
|
"project_rulebook_subscriptions", "project_rule_suppressions",
|
||||||
"project_topic_suppressions",
|
"project_topic_suppressions", "project_rulebook_exclusions",
|
||||||
# v5 (2026-08): the five-year gap this list was written to stop.
|
# v5 (2026-08): the five-year gap this list was written to stop.
|
||||||
"systems", "record_systems", "design_systems", "design_tokens",
|
"systems", "record_systems", "design_systems", "design_tokens",
|
||||||
"note_usage_events", "repo_bindings", "note_supersessions",
|
"note_usage_events", "repo_bindings", "note_supersessions",
|
||||||
# v7 (2026-08): the shape ledger (#2787); v8: its history (#2793).
|
# v7 (2026-08): the shape ledger (#2787); v8: its history (#2793).
|
||||||
"code_shapes", "code_shape_events", "code_shape_uses",
|
"code_shapes", "code_shape_events", "code_shape_uses",
|
||||||
|
# v9 (2026-08): the global area catalog (milestone 307). Global, not
|
||||||
|
# user-scoped, so it rides in EVERY export — including a single-user
|
||||||
|
# one, whose Systems would otherwise restore unmapped.
|
||||||
|
"canonical_systems",
|
||||||
|
# v10 (2026-08): a rule's area tag and its typed edges (milestone 307).
|
||||||
|
"rule_systems", "rule_relations",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||||
# explicit rather than silent. ACL (groups/shares) is a coherent follow-up;
|
# explicit rather than silent. ACL (groups/shares) is a coherent follow-up;
|
||||||
# note_embeddings are derived (regenerated from note bodies); api_keys are
|
# note_embeddings and rule_embeddings are derived (regenerated at startup
|
||||||
|
# from the records themselves, which is also how a chunker bump is handled); api_keys are
|
||||||
# sensitive credentials; retrieval_logs is observational telemetry that nothing
|
# sensitive credentials; retrieval_logs is observational telemetry that nothing
|
||||||
# reads for correctness and that grows per query; the rest are
|
# reads for correctness and that grows per query; the rest are
|
||||||
# transient/operational.
|
# transient/operational.
|
||||||
@@ -80,7 +92,7 @@ _BACKED_UP = [
|
|||||||
# like coverage while naming nothing the schema could confirm.
|
# like coverage while naming nothing the schema could confirm.
|
||||||
_NOT_INCLUDED = [
|
_NOT_INCLUDED = [
|
||||||
"groups", "group_memberships", "project_shares", "note_shares",
|
"groups", "group_memberships", "project_shares", "note_shares",
|
||||||
"api_keys", "note_embeddings", "app_logs", "notifications",
|
"api_keys", "note_embeddings", "rule_embeddings", "app_logs", "notifications",
|
||||||
"invitation_tokens", "password_reset_tokens", "user_profiles",
|
"invitation_tokens", "password_reset_tokens", "user_profiles",
|
||||||
"retrieval_logs",
|
"retrieval_logs",
|
||||||
# Sensitive credentials, same reasoning as api_keys: a backup that carries
|
# Sensitive credentials, same reasoning as api_keys: a backup that carries
|
||||||
@@ -89,6 +101,10 @@ _NOT_INCLUDED = [
|
|||||||
# deliberately not exported either, so restored projects fall back to
|
# deliberately not exported either, so restored projects fall back to
|
||||||
# keyring-by-host resolution — the documented unpinned behavior (#2778).
|
# keyring-by-host resolution — the documented unpinned behavior (#2778).
|
||||||
"forge_connections",
|
"forge_connections",
|
||||||
|
# Derived, like note_embeddings: the CSS consumer map (milestone 302) is
|
||||||
|
# rebuilt from the repo archive by every coverage sync, and carries no
|
||||||
|
# judgment — the first refresh after a restore recreates it exactly.
|
||||||
|
"code_shape_consumers",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -112,16 +128,37 @@ def _topic_suppression_rows(rows) -> list[dict]:
|
|||||||
return [{"project_id": r.project_id, "topic_id": r.topic_id} for r in rows]
|
return [{"project_id": r.project_id, "topic_id": r.topic_id} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _rulebook_exclusion_rows(rows) -> list[dict]:
|
||||||
|
return [{"project_id": r.project_id, "rulebook_id": r.rulebook_id} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# The v5 sections. Pure row-builders like the join-table helpers above, for the
|
# The v5 sections. Pure row-builders like the join-table helpers above, for the
|
||||||
# same reason: CI has no database, so a serialiser that is a plain function is
|
# same reason: CI has no database, so a serialiser that is a plain function is
|
||||||
# one that can actually be tested.
|
# one that can actually be tested.
|
||||||
|
|
||||||
def _system_rows(rows) -> list[dict]:
|
def _canonical_system_rows(rows) -> list[dict]:
|
||||||
|
"""The global area catalog. Carried WITHOUT ids: a restore matches on slug,
|
||||||
|
so a target install that already seeded the standard vocabulary reuses its
|
||||||
|
own rows and only gains the entries an admin added here."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": r.name, "slug": r.slug, "description": r.description,
|
||||||
|
"order_index": r.order_index,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _system_rows(rows, canonical_slugs: dict[int, str]) -> list[dict]:
|
||||||
|
"""A project's Systems. The canonical mapping travels as a SLUG, not an id
|
||||||
|
— the catalog is global and its ids are per-install, so an id would restore
|
||||||
|
pointing at whatever area happened to land on that number."""
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": r.id, "user_id": r.user_id, "project_id": r.project_id,
|
"id": r.id, "user_id": r.user_id, "project_id": r.project_id,
|
||||||
"name": r.name, "description": r.description, "color": r.color,
|
"name": r.name, "description": r.description, "color": r.color,
|
||||||
"status": r.status, "order_index": r.order_index,
|
"status": r.status, "order_index": r.order_index,
|
||||||
|
"canonical_slug": canonical_slugs.get(r.canonical_id or 0),
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@@ -219,6 +256,8 @@ def _project_rows(rows) -> list[dict]:
|
|||||||
"id": p.id, "user_id": p.user_id, "title": p.title,
|
"id": p.id, "user_id": p.user_id, "title": p.title,
|
||||||
"description": p.description, "goal": p.goal, "status": p.status,
|
"description": p.description, "goal": p.goal, "status": p.status,
|
||||||
"color": p.color,
|
"color": p.color,
|
||||||
|
"design_system_id": p.design_system_id,
|
||||||
|
"inception": p.inception,
|
||||||
"created_at": p.created_at.isoformat(),
|
"created_at": p.created_at.isoformat(),
|
||||||
"updated_at": p.updated_at.isoformat(),
|
"updated_at": p.updated_at.isoformat(),
|
||||||
}
|
}
|
||||||
@@ -319,12 +358,34 @@ def _topic_rows(rows) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_system_rows(rows) -> list[dict]:
|
||||||
|
"""A rule's area tags, carried by canonical SLUG for the same reason the
|
||||||
|
Systems are: the catalog is global and its ids are per-install."""
|
||||||
|
return [{"rule_id": rule_id, "canonical_slug": slug} for rule_id, slug in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_relation_rows(rows) -> list[dict]:
|
||||||
|
"""The typed edges between rules. Carried because they are a JUDGEMENT —
|
||||||
|
someone decided these two fail together, or that one supersedes the other,
|
||||||
|
and nothing in either rule's text records the decision. Lose them and a
|
||||||
|
split rule silently starts arriving half at a time again."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"from_rule_id": r.from_rule_id, "to_rule_id": r.to_rule_id,
|
||||||
|
"kind": r.kind, "note": r.note,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _rule_rows(rows) -> list[dict]:
|
def _rule_rows(rows) -> list[dict]:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": r.id, "topic_id": r.topic_id, "project_id": r.project_id,
|
"id": r.id, "topic_id": r.topic_id, "project_id": r.project_id,
|
||||||
"title": r.title, "statement": r.statement, "why": r.why,
|
"title": r.title, "statement": r.statement, "why": r.why,
|
||||||
"how_to_apply": r.how_to_apply, "order_index": r.order_index,
|
"how_to_apply": r.how_to_apply, "order_index": r.order_index,
|
||||||
|
"when_to_apply": r.when_to_apply, "tier": r.tier,
|
||||||
|
"arose_from_id": r.arose_from_id,
|
||||||
"created_at": r.created_at.isoformat(),
|
"created_at": r.created_at.isoformat(),
|
||||||
"updated_at": r.updated_at.isoformat(),
|
"updated_at": r.updated_at.isoformat(),
|
||||||
}
|
}
|
||||||
@@ -350,6 +411,15 @@ async def export_full_backup() -> dict:
|
|||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
settings = (await session.execute(select(Setting))).scalars().all()
|
settings = (await session.execute(select(Setting))).scalars().all()
|
||||||
systems = (await session.execute(select(System))).scalars().all()
|
systems = (await session.execute(select(System))).scalars().all()
|
||||||
|
canonical_systems = (await session.execute(
|
||||||
|
select(CanonicalSystem).where(CanonicalSystem.deleted_at.is_(None))
|
||||||
|
.order_by(CanonicalSystem.order_index)
|
||||||
|
)).scalars().all()
|
||||||
|
rule_system_rows = (await session.execute(
|
||||||
|
select(rule_systems_t.c.rule_id, CanonicalSystem.slug)
|
||||||
|
.join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id)
|
||||||
|
)).all()
|
||||||
|
rule_relations = (await session.execute(select(RuleRelation))).scalars().all()
|
||||||
record_systems = (await session.execute(select(RecordSystem))).scalars().all()
|
record_systems = (await session.execute(select(RecordSystem))).scalars().all()
|
||||||
supersessions = (
|
supersessions = (
|
||||||
await session.execute(select(NoteSupersession))
|
await session.execute(select(NoteSupersession))
|
||||||
@@ -383,6 +453,9 @@ async def export_full_backup() -> dict:
|
|||||||
topic_suppressions = (await session.execute(
|
topic_suppressions = (await session.execute(
|
||||||
select(project_topic_suppressions)
|
select(project_topic_suppressions)
|
||||||
)).all()
|
)).all()
|
||||||
|
rulebook_exclusions = (await session.execute(
|
||||||
|
select(project_rulebook_exclusions)
|
||||||
|
)).all()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version": BACKUP_VERSION,
|
"version": BACKUP_VERSION,
|
||||||
@@ -407,7 +480,13 @@ async def export_full_backup() -> dict:
|
|||||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||||
"systems": _system_rows(systems),
|
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
|
||||||
|
"canonical_systems": _canonical_system_rows(canonical_systems),
|
||||||
|
"rule_systems": _rule_system_rows(rule_system_rows),
|
||||||
|
"rule_relations": _rule_relation_rows(rule_relations),
|
||||||
|
"systems": _system_rows(
|
||||||
|
systems, {c.id: c.slug for c in canonical_systems}
|
||||||
|
),
|
||||||
"record_systems": _record_system_rows(record_systems),
|
"record_systems": _record_system_rows(record_systems),
|
||||||
"design_systems": _design_system_rows(design_systems),
|
"design_systems": _design_system_rows(design_systems),
|
||||||
"design_tokens": _design_token_rows(design_tokens),
|
"design_tokens": _design_token_rows(design_tokens),
|
||||||
@@ -450,6 +529,12 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
systems = (await session.execute(
|
systems = (await session.execute(
|
||||||
select(System).where(System.user_id == user_id)
|
select(System).where(System.user_id == user_id)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
|
# Global: taken whole even in a per-user export, because the Systems
|
||||||
|
# above reference it and a partial catalog restores partial mappings.
|
||||||
|
canonical_systems = (await session.execute(
|
||||||
|
select(CanonicalSystem).where(CanonicalSystem.deleted_at.is_(None))
|
||||||
|
.order_by(CanonicalSystem.order_index)
|
||||||
|
)).scalars().all()
|
||||||
system_ids = [sy.id for sy in systems]
|
system_ids = [sy.id for sy in systems]
|
||||||
note_ids = [n.id for n in notes]
|
note_ids = [n.id for n in notes]
|
||||||
# Scoped by the user's SYSTEMS, not their notes: a shared note carrying
|
# Scoped by the user's SYSTEMS, not their notes: a shared note carrying
|
||||||
@@ -516,6 +601,20 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
rules = (await session.execute(
|
rules = (await session.execute(
|
||||||
select(Rule).where(or_(*rule_filters))
|
select(Rule).where(or_(*rule_filters))
|
||||||
)).scalars().all() if rule_filters else []
|
)).scalars().all() if rule_filters else []
|
||||||
|
# Scoped to the rules this export already carries: an edge whose far
|
||||||
|
# end is absent would restore pointing at nothing.
|
||||||
|
_rule_ids = [r.id for r in rules]
|
||||||
|
rule_system_rows = (await session.execute(
|
||||||
|
select(rule_systems_t.c.rule_id, CanonicalSystem.slug)
|
||||||
|
.join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id)
|
||||||
|
.where(rule_systems_t.c.rule_id.in_(_rule_ids))
|
||||||
|
)).all() if _rule_ids else []
|
||||||
|
rule_relations = (await session.execute(
|
||||||
|
select(RuleRelation).where(
|
||||||
|
RuleRelation.from_rule_id.in_(_rule_ids),
|
||||||
|
RuleRelation.to_rule_id.in_(_rule_ids),
|
||||||
|
)
|
||||||
|
)).scalars().all() if _rule_ids else []
|
||||||
if project_ids:
|
if project_ids:
|
||||||
subscriptions = (await session.execute(
|
subscriptions = (await session.execute(
|
||||||
select(project_rulebook_subscriptions).where(
|
select(project_rulebook_subscriptions).where(
|
||||||
@@ -532,8 +631,13 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
project_topic_suppressions.c.project_id.in_(project_ids)
|
project_topic_suppressions.c.project_id.in_(project_ids)
|
||||||
)
|
)
|
||||||
)).all()
|
)).all()
|
||||||
|
rulebook_exclusions = (await session.execute(
|
||||||
|
select(project_rulebook_exclusions).where(
|
||||||
|
project_rulebook_exclusions.c.project_id.in_(project_ids)
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
else:
|
else:
|
||||||
subscriptions = rule_suppressions = topic_suppressions = []
|
subscriptions = rule_suppressions = topic_suppressions = rulebook_exclusions = []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version": BACKUP_VERSION,
|
"version": BACKUP_VERSION,
|
||||||
@@ -560,7 +664,13 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
"rulebook_subscriptions": _subscription_rows(subscriptions),
|
||||||
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
"rule_suppressions": _rule_suppression_rows(rule_suppressions),
|
||||||
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
"topic_suppressions": _topic_suppression_rows(topic_suppressions),
|
||||||
"systems": _system_rows(systems),
|
"rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions),
|
||||||
|
"canonical_systems": _canonical_system_rows(canonical_systems),
|
||||||
|
"rule_systems": _rule_system_rows(rule_system_rows),
|
||||||
|
"rule_relations": _rule_relation_rows(rule_relations),
|
||||||
|
"systems": _system_rows(
|
||||||
|
systems, {c.id: c.slug for c in canonical_systems}
|
||||||
|
),
|
||||||
"record_systems": _record_system_rows(record_systems),
|
"record_systems": _record_system_rows(record_systems),
|
||||||
"design_systems": _design_system_rows(design_systems),
|
"design_systems": _design_system_rows(design_systems),
|
||||||
"design_tokens": _design_token_rows(design_tokens),
|
"design_tokens": _design_token_rows(design_tokens),
|
||||||
@@ -670,11 +780,12 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
"task_logs": 0, "note_drafts": 0, "note_versions": 0,
|
"task_logs": 0, "note_drafts": 0, "note_versions": 0,
|
||||||
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
|
"settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0,
|
||||||
"rulebook_subscriptions": 0, "rule_suppressions": 0,
|
"rulebook_subscriptions": 0, "rule_suppressions": 0,
|
||||||
"topic_suppressions": 0,
|
"topic_suppressions": 0, "rulebook_exclusions": 0,
|
||||||
"systems": 0, "record_systems": 0, "design_systems": 0,
|
"systems": 0, "record_systems": 0, "design_systems": 0,
|
||||||
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
|
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
|
||||||
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
|
"note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0,
|
||||||
"code_shape_uses": 0,
|
"code_shape_uses": 0, "canonical_systems": 0,
|
||||||
|
"rule_systems": 0, "rule_relations": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
@@ -891,6 +1002,11 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
statement=r_data.get("statement", ""),
|
statement=r_data.get("statement", ""),
|
||||||
why=r_data.get("why") or None,
|
why=r_data.get("why") or None,
|
||||||
how_to_apply=r_data.get("how_to_apply") or None,
|
how_to_apply=r_data.get("how_to_apply") or None,
|
||||||
|
when_to_apply=r_data.get("when_to_apply") or None,
|
||||||
|
# A file written before migration 0088 has no tier. always_on
|
||||||
|
# is the pre-0088 behaviour, so an old backup restores rules
|
||||||
|
# that bind exactly as they did when it was taken.
|
||||||
|
tier=r_data.get("tier") or "always_on",
|
||||||
order_index=r_data.get("order_index", 0),
|
order_index=r_data.get("order_index", 0),
|
||||||
created_at=_dt(r_data.get("created_at")),
|
created_at=_dt(r_data.get("created_at")),
|
||||||
updated_at=_dt(r_data.get("updated_at")),
|
updated_at=_dt(r_data.get("updated_at")),
|
||||||
@@ -933,11 +1049,72 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
))
|
))
|
||||||
stats["topic_suppressions"] += 1
|
stats["topic_suppressions"] += 1
|
||||||
|
|
||||||
|
# 14b. Always-on rulebook exclusions (v10, milestone 297)
|
||||||
|
for exc in data.get("rulebook_exclusions", []):
|
||||||
|
mapped_pid = project_id_map.get(exc.get("project_id", 0))
|
||||||
|
mapped_rbid = rulebook_id_map.get(exc.get("rulebook_id", 0))
|
||||||
|
if mapped_pid is None or mapped_rbid is None:
|
||||||
|
continue
|
||||||
|
await session.execute(project_rulebook_exclusions.insert().values(
|
||||||
|
project_id=mapped_pid, rulebook_id=mapped_rbid,
|
||||||
|
))
|
||||||
|
stats["rulebook_exclusions"] += 1
|
||||||
|
|
||||||
# --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4
|
# --- v5 sections. Every one is data.get()-guarded, so a v2/v3/v4
|
||||||
# payload restores without them rather than failing on an absent key.
|
# payload restores without them rather than failing on an absent key.
|
||||||
|
|
||||||
# 15. Systems
|
|
||||||
system_id_map: dict[int, int] = {}
|
system_id_map: dict[int, int] = {}
|
||||||
|
|
||||||
|
# 14c. The global area catalog, matched on SLUG. This install already
|
||||||
|
# has the standard vocabulary from its migrations, so the common case
|
||||||
|
# adds nothing and simply learns which local id each slug is; only an
|
||||||
|
# entry an admin added on the source instance is created here. Runs
|
||||||
|
# BEFORE systems, which resolve their mapping through this map.
|
||||||
|
canonical_id_by_slug: dict[str, int] = {}
|
||||||
|
existing_canonical = (await session.execute(
|
||||||
|
select(CanonicalSystem).where(CanonicalSystem.deleted_at.is_(None))
|
||||||
|
)).scalars().all()
|
||||||
|
for entry in existing_canonical:
|
||||||
|
canonical_id_by_slug[entry.slug] = entry.id
|
||||||
|
for cs_data in data.get("canonical_systems", []):
|
||||||
|
slug = cs_data.get("slug") or ""
|
||||||
|
if not slug or slug in canonical_id_by_slug:
|
||||||
|
continue
|
||||||
|
entry = CanonicalSystem(
|
||||||
|
name=cs_data.get("name", ""), slug=slug,
|
||||||
|
description=cs_data.get("description"),
|
||||||
|
order_index=cs_data.get("order_index", 0),
|
||||||
|
)
|
||||||
|
session.add(entry)
|
||||||
|
await session.flush()
|
||||||
|
canonical_id_by_slug[slug] = entry.id
|
||||||
|
stats["canonical_systems"] += 1
|
||||||
|
|
||||||
|
# 14d. A rule's area tags and its typed edges. Runs HERE, not beside the
|
||||||
|
# rules in section 11, because it needs both maps: the rule ids from
|
||||||
|
# there and the canonical slugs from 14c just above.
|
||||||
|
for rs in data.get("rule_systems", []):
|
||||||
|
mapped_rule = rule_id_map.get(rs.get("rule_id", 0))
|
||||||
|
canonical_id = canonical_id_by_slug.get(rs.get("canonical_slug") or "")
|
||||||
|
if mapped_rule is None or canonical_id is None:
|
||||||
|
continue
|
||||||
|
await session.execute(rule_systems_t.insert().values(
|
||||||
|
rule_id=mapped_rule, canonical_id=canonical_id,
|
||||||
|
))
|
||||||
|
stats["rule_systems"] += 1
|
||||||
|
|
||||||
|
for rr in data.get("rule_relations", []):
|
||||||
|
mapped_from = rule_id_map.get(rr.get("from_rule_id", 0))
|
||||||
|
mapped_to = rule_id_map.get(rr.get("to_rule_id", 0))
|
||||||
|
if mapped_from is None or mapped_to is None or mapped_from == mapped_to:
|
||||||
|
continue
|
||||||
|
session.add(RuleRelation(
|
||||||
|
from_rule_id=mapped_from, to_rule_id=mapped_to,
|
||||||
|
kind=rr.get("kind", "co_surfaces"), note=rr.get("note") or None,
|
||||||
|
))
|
||||||
|
stats["rule_relations"] += 1
|
||||||
|
|
||||||
|
# 15. Systems
|
||||||
for sy_data in data.get("systems", []):
|
for sy_data in data.get("systems", []):
|
||||||
mapped_uid = user_id_map.get(sy_data.get("user_id", 0))
|
mapped_uid = user_id_map.get(sy_data.get("user_id", 0))
|
||||||
mapped_pid = project_id_map.get(sy_data.get("project_id", 0))
|
mapped_pid = project_id_map.get(sy_data.get("project_id", 0))
|
||||||
@@ -950,6 +1127,9 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
color=sy_data.get("color"),
|
color=sy_data.get("color"),
|
||||||
status=sy_data.get("status", "active"),
|
status=sy_data.get("status", "active"),
|
||||||
order_index=sy_data.get("order_index", 0),
|
order_index=sy_data.get("order_index", 0),
|
||||||
|
# An unknown slug restores UNMAPPED rather than failing: the
|
||||||
|
# System and its records are the payload, the mapping is an aid.
|
||||||
|
canonical_id=canonical_id_by_slug.get(sy_data.get("canonical_slug") or ""),
|
||||||
)
|
)
|
||||||
session.add(system)
|
session.add(system)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -1137,6 +1317,35 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
))
|
))
|
||||||
stats["code_shape_uses"] += 1
|
stats["code_shape_uses"] += 1
|
||||||
|
|
||||||
|
# v10: a project's design-system pointer and its inception record ride
|
||||||
|
# the project but point at design systems and rulebooks restored AFTER
|
||||||
|
# it — so they are written last, with ids re-mapped. An id that did
|
||||||
|
# not survive drops out of the record rather than dangling.
|
||||||
|
for p_data in data.get("projects", []):
|
||||||
|
new_pid = project_id_map.get(p_data.get("id") or 0)
|
||||||
|
if new_pid is None:
|
||||||
|
continue
|
||||||
|
proj = await session.get(Project, new_pid)
|
||||||
|
if proj is None:
|
||||||
|
continue
|
||||||
|
old_ds = p_data.get("design_system_id")
|
||||||
|
if old_ds:
|
||||||
|
proj.design_system_id = design_system_id_map.get(old_ds)
|
||||||
|
inception = p_data.get("inception")
|
||||||
|
if isinstance(inception, dict):
|
||||||
|
choices = dict(inception.get("choices") or {})
|
||||||
|
choices["exclude_always_on_rulebooks"] = [
|
||||||
|
rulebook_id_map[i] for i in choices.get("exclude_always_on_rulebooks") or []
|
||||||
|
if i in rulebook_id_map
|
||||||
|
]
|
||||||
|
choices["subscribe_rulebooks"] = [
|
||||||
|
rulebook_id_map[i] for i in choices.get("subscribe_rulebooks") or []
|
||||||
|
if i in rulebook_id_map
|
||||||
|
]
|
||||||
|
ds = choices.get("design_system_id")
|
||||||
|
choices["design_system_id"] = design_system_id_map.get(ds) if ds else None
|
||||||
|
proj.inception = {**inception, "choices": choices}
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
logger.info("Restored v2/v3 backup: %s", stats)
|
logger.info("Restored v2/v3 backup: %s", stats)
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""The global canonical area vocabulary, and the mapping from a project's
|
||||||
|
Systems onto it (milestone 307 step 1, decision note 3026).
|
||||||
|
|
||||||
|
A `System` is per-project. Nothing outside a project can reference one, so a
|
||||||
|
rule that spans projects has no way to say "this is about CI" without chaining
|
||||||
|
itself to one project's row. `CanonicalSystem` is that join key, and it is
|
||||||
|
GLOBAL — no `user_id`, so a shared project inherits the vocabulary instead of
|
||||||
|
re-earning it.
|
||||||
|
|
||||||
|
Two rules govern everything here:
|
||||||
|
|
||||||
|
- **Associate, never rewrite.** Mapping a System sets `systems.canonical_id`
|
||||||
|
and nothing else. The local name stays whatever the project calls the area,
|
||||||
|
and `record_systems` is never touched — no record's tags move.
|
||||||
|
- **Propose, never decide.** An exact slug hit is mechanical and maps on
|
||||||
|
request; anything short of that is a PROPOSAL a human confirms. "CI &
|
||||||
|
Release" vs "CI & runners" is a judgment call, and the cost of guessing it
|
||||||
|
wrong silently is a rule surfacing in the wrong project.
|
||||||
|
|
||||||
|
Reads are open to any authenticated caller (the catalog is shared vocabulary,
|
||||||
|
not user data). Writes to the catalog itself are admin-only: a global table
|
||||||
|
that anyone can extend is how a shared vocabulary stops being shared.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from scribe.models import async_session
|
||||||
|
from scribe.models.canonical_system import CanonicalSystem
|
||||||
|
from scribe.models.system import System
|
||||||
|
from scribe.models.user import User
|
||||||
|
from scribe.services import access
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Tokens that carry no meaning for matching — "&" becomes "and" before the
|
||||||
|
# split, so it would otherwise dominate the overlap score of every pair.
|
||||||
|
_NOISE_TOKENS = frozenset({"and", "the", "a", "of"})
|
||||||
|
|
||||||
|
_NON_ALNUM = re.compile(r"[^a-z0-9]+")
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_slug(name: str) -> str:
|
||||||
|
"""The match key for an area name — NOT a display value.
|
||||||
|
|
||||||
|
Folds exactly the spelling differences that produced three names for one
|
||||||
|
area on the author's instance: `CI & Release`, `CI and Release` and
|
||||||
|
`CI & release` all slug to `ci-and-release`, so they map mechanically.
|
||||||
|
A real difference survives: `CI & runners` slugs to `ci-and-runners` and
|
||||||
|
goes through the proposal path where a human decides.
|
||||||
|
"""
|
||||||
|
lowered = name.strip().lower().replace("&", " and ")
|
||||||
|
return "-".join(_NON_ALNUM.sub(" ", lowered).split())
|
||||||
|
|
||||||
|
|
||||||
|
def _tokens(slug: str) -> frozenset[str]:
|
||||||
|
return frozenset(slug.split("-")) - _NOISE_TOKENS
|
||||||
|
|
||||||
|
|
||||||
|
async def _is_admin(user_id: int) -> bool:
|
||||||
|
async with async_session() as session:
|
||||||
|
role = await session.scalar(select(User.role).where(User.id == user_id))
|
||||||
|
return role == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
async def list_canonical_systems() -> list[CanonicalSystem]:
|
||||||
|
"""The whole catalog, in display order. Global — no ownership filter."""
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(CanonicalSystem)
|
||||||
|
.where(CanonicalSystem.deleted_at.is_(None))
|
||||||
|
.order_by(CanonicalSystem.order_index.asc(), CanonicalSystem.name.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_canonical_system(canonical_id: int) -> CanonicalSystem | None:
|
||||||
|
async with async_session() as session:
|
||||||
|
entry = await session.get(CanonicalSystem, canonical_id)
|
||||||
|
return entry if entry is not None and entry.deleted_at is None else None
|
||||||
|
|
||||||
|
|
||||||
|
async def find_by_name(name: str) -> CanonicalSystem | None:
|
||||||
|
"""The exact-slug lookup — the mechanical half of matching."""
|
||||||
|
slug = canonical_slug(name)
|
||||||
|
if not slug:
|
||||||
|
return None
|
||||||
|
async with async_session() as session:
|
||||||
|
return await session.scalar(
|
||||||
|
select(CanonicalSystem).where(
|
||||||
|
CanonicalSystem.slug == slug,
|
||||||
|
CanonicalSystem.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _overlap(local: frozenset[str], other: frozenset[str]) -> float:
|
||||||
|
return len(local & other) / max(len(local | other), 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def best_overlap(name: str, catalog: list | None = None) -> dict | None:
|
||||||
|
"""The closest catalog entry that shares a meaningful word, or None.
|
||||||
|
|
||||||
|
The ONE scorer behind both offers: the create-time suggestion and the
|
||||||
|
review surface. Two scorers would eventually disagree about which area a
|
||||||
|
name resembles, and the operator would be asked one question at create
|
||||||
|
time and a different one at review.
|
||||||
|
|
||||||
|
The threshold is any shared meaningful word, deliberately generous: a
|
||||||
|
wrong offer costs one dismissal, a missing one costs a mapping nobody
|
||||||
|
thinks to make again. Nothing here ever applies — `overlap` is always an
|
||||||
|
offer (see propose_mappings).
|
||||||
|
"""
|
||||||
|
slug = canonical_slug(name)
|
||||||
|
if not slug:
|
||||||
|
return None
|
||||||
|
local = _tokens(slug)
|
||||||
|
if not local:
|
||||||
|
return None
|
||||||
|
# A caller already holding the catalog passes it: this runs once per
|
||||||
|
# unmapped System in the review sweep, and re-reading the table each time
|
||||||
|
# would make an N+1 out of a report.
|
||||||
|
if catalog is None:
|
||||||
|
catalog = await list_canonical_systems()
|
||||||
|
best, best_score = None, 0.0
|
||||||
|
for entry in catalog:
|
||||||
|
score = _overlap(local, _tokens(entry.slug))
|
||||||
|
if score > best_score:
|
||||||
|
best, best_score = entry, score
|
||||||
|
if best is None or best_score <= 0:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": best.id, "name": best.name,
|
||||||
|
"basis": "overlap", "score": round(best_score, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def create_canonical_system(
|
||||||
|
user_id: int, name: str, description: str | None = None,
|
||||||
|
) -> CanonicalSystem | dict | None:
|
||||||
|
"""Add an area to the global catalog. Admin only.
|
||||||
|
|
||||||
|
Duplicate-gated on the SLUG, not the raw name, so "CI and Release" cannot
|
||||||
|
be added alongside "CI & Release" — that is the drift this table exists to
|
||||||
|
end. Returns the existing entry's id instead of creating a second one.
|
||||||
|
"""
|
||||||
|
if not await _is_admin(user_id):
|
||||||
|
return None
|
||||||
|
slug = canonical_slug(name)
|
||||||
|
if not slug:
|
||||||
|
return None
|
||||||
|
existing = await find_by_name(name)
|
||||||
|
if existing is not None:
|
||||||
|
return {
|
||||||
|
"duplicate": True,
|
||||||
|
"existing_id": existing.id,
|
||||||
|
"message": (
|
||||||
|
f"'{existing.name}' (#{existing.id}) already covers this area — "
|
||||||
|
f"both names reduce to '{slug}'. Map Systems to it, or "
|
||||||
|
"update_canonical_system if the charter needs revising."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
async with async_session() as session:
|
||||||
|
highest = await session.scalar(
|
||||||
|
select(CanonicalSystem.order_index)
|
||||||
|
.order_by(CanonicalSystem.order_index.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
entry = CanonicalSystem(
|
||||||
|
name=" ".join(name.split()),
|
||||||
|
slug=slug,
|
||||||
|
description=description,
|
||||||
|
order_index=(highest or 0) + 1,
|
||||||
|
)
|
||||||
|
session.add(entry)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
async def update_canonical_system(
|
||||||
|
user_id: int, canonical_id: int, **fields: object,
|
||||||
|
) -> CanonicalSystem | None:
|
||||||
|
"""Rename or re-charter a catalog entry. Admin only.
|
||||||
|
|
||||||
|
A rename recomputes the slug — the display name and the match key must not
|
||||||
|
be allowed to disagree, or the exact-match path silently stops finding it.
|
||||||
|
"""
|
||||||
|
if not await _is_admin(user_id):
|
||||||
|
return None
|
||||||
|
allowed = {"name", "description", "order_index"}
|
||||||
|
async with async_session() as session:
|
||||||
|
entry = await session.get(CanonicalSystem, canonical_id)
|
||||||
|
if entry is None or entry.deleted_at is not None:
|
||||||
|
return None
|
||||||
|
for key, value in fields.items():
|
||||||
|
if key in allowed and value is not None:
|
||||||
|
setattr(entry, key, value)
|
||||||
|
if "name" in fields and fields["name"]:
|
||||||
|
entry.name = " ".join(str(fields["name"]).split())
|
||||||
|
entry.slug = canonical_slug(entry.name)
|
||||||
|
entry.updated_at = datetime.now(timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
async def set_system_canonical(
|
||||||
|
user_id: int, system_id: int, canonical_id: int | None,
|
||||||
|
) -> System | None:
|
||||||
|
"""Map (or unmap) one project System onto a catalog entry.
|
||||||
|
|
||||||
|
Authorised by the PROJECT, not the catalog: mapping changes the project's
|
||||||
|
row, so project write access is the right gate (rule 78 — never a bare
|
||||||
|
owner filter). Passing None clears the mapping.
|
||||||
|
|
||||||
|
Touches `canonical_id` and nothing else — the System's own name, charter
|
||||||
|
and record associations are left exactly as they are.
|
||||||
|
"""
|
||||||
|
if canonical_id is not None and await get_canonical_system(canonical_id) is None:
|
||||||
|
return None
|
||||||
|
async with async_session() as session:
|
||||||
|
system = await session.get(System, system_id)
|
||||||
|
if system is None or system.deleted_at is not None:
|
||||||
|
return None
|
||||||
|
if not await access.can_write_project(user_id, system.project_id):
|
||||||
|
return None
|
||||||
|
system.canonical_id = canonical_id
|
||||||
|
system.updated_at = datetime.now(timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(system)
|
||||||
|
return system
|
||||||
|
|
||||||
|
|
||||||
|
async def propose_mappings(user_id: int, project_id: int) -> list[dict]:
|
||||||
|
"""Suggest a catalog entry for each of a project's UNMAPPED Systems.
|
||||||
|
|
||||||
|
Returns proposals, never applied changes — `set_system_canonical` is the
|
||||||
|
only thing that writes. Each carries a `basis` so the reviewer knows what
|
||||||
|
they are approving:
|
||||||
|
|
||||||
|
- `exact` — the two names reduce to the same slug. Mechanical.
|
||||||
|
- `overlap` — they share a meaningful word ("CI & runners" / "CI &
|
||||||
|
Release"). A judgment call, and the reason this is a proposal at all.
|
||||||
|
|
||||||
|
A System with no plausible match simply gets no proposal: unmapped is a
|
||||||
|
perfectly good resting state, so silence here is an answer, not a gap.
|
||||||
|
"""
|
||||||
|
if not await access.can_read_project(user_id, project_id):
|
||||||
|
return []
|
||||||
|
catalog = await list_canonical_systems()
|
||||||
|
if not catalog:
|
||||||
|
return []
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(System).where(
|
||||||
|
System.project_id == project_id,
|
||||||
|
System.canonical_id.is_(None),
|
||||||
|
System.deleted_at.is_(None),
|
||||||
|
).order_by(System.order_index.asc(), System.created_at.asc())
|
||||||
|
)
|
||||||
|
systems = list(result.scalars().all())
|
||||||
|
|
||||||
|
by_slug = {entry.slug: entry for entry in catalog}
|
||||||
|
proposals: list[dict] = []
|
||||||
|
for system in systems:
|
||||||
|
slug = canonical_slug(system.name)
|
||||||
|
if not slug:
|
||||||
|
continue
|
||||||
|
exact = by_slug.get(slug)
|
||||||
|
if exact is not None:
|
||||||
|
match = {"id": exact.id, "name": exact.name, "basis": "exact", "score": 1.0}
|
||||||
|
else:
|
||||||
|
# Same scorer the create-time offer uses, so the two surfaces can
|
||||||
|
# never name different areas for one System.
|
||||||
|
match = await best_overlap(system.name, catalog)
|
||||||
|
if match is None:
|
||||||
|
continue
|
||||||
|
proposals.append({
|
||||||
|
"system_id": system.id,
|
||||||
|
"system_name": system.name,
|
||||||
|
"canonical_id": match["id"],
|
||||||
|
"canonical_name": match["name"],
|
||||||
|
"basis": match["basis"],
|
||||||
|
"score": match["score"],
|
||||||
|
})
|
||||||
|
proposals.sort(key=lambda p: (-p["score"], p["system_name"]))
|
||||||
|
return proposals
|
||||||
+270
-29
@@ -123,6 +123,12 @@ def _definition_on(raw: str) -> tuple[str, str] | None:
|
|||||||
name = m.group(1)
|
name = m.group(1)
|
||||||
if name.startswith("__") and name.endswith("__"):
|
if name.startswith("__") and name.endswith("__"):
|
||||||
return None
|
return None
|
||||||
|
# `type` announces a definition only when something is declared after
|
||||||
|
# the name (`type Foo = …`, `type Foo struct {`); an import specifier
|
||||||
|
# (`import { type Foo, bar }`) is the same two words and defines
|
||||||
|
# nothing — it showed up as a two-file "identical body" family (#2904).
|
||||||
|
if line.startswith("type") and not re.search(r"[={]", line[m.end():]):
|
||||||
|
return None
|
||||||
return ("sym", name)
|
return ("sym", name)
|
||||||
if m := _ARROW_RE.match(line):
|
if m := _ARROW_RE.match(line):
|
||||||
return ("sym", m.group(1))
|
return ("sym", m.group(1))
|
||||||
@@ -156,6 +162,12 @@ def _block_sha(lines: list[str]) -> str:
|
|||||||
return hashlib.sha1("\n".join(kept).encode("utf-8")).hexdigest()[:16]
|
return hashlib.sha1("\n".join(kept).encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _declaration_count(lines: list[str]) -> int:
|
||||||
|
"""How many `prop: value` declarations a CSS block body carries."""
|
||||||
|
body = " ".join(lines)
|
||||||
|
return sum(1 for part in body.replace("}", "").split(";") if ":" in part)
|
||||||
|
|
||||||
|
|
||||||
def extract_definitions(text: str) -> list[Definition]:
|
def extract_definitions(text: str) -> list[Definition]:
|
||||||
"""Every definition this text makes, with signature + fingerprint.
|
"""Every definition this text makes, with signature + fingerprint.
|
||||||
|
|
||||||
@@ -186,11 +198,13 @@ def extract_definitions(text: str) -> list[Definition]:
|
|||||||
break
|
break
|
||||||
block = lines[i:end]
|
block = lines[i:end]
|
||||||
# A CSS rule's fingerprint is its DECLARATIONS, not its selector
|
# A CSS rule's fingerprint is its DECLARATIONS, not its selector
|
||||||
# (#2872): the row's identity already carries the selector, and the
|
# (#2872): the row's identity already carries the selector. Since
|
||||||
# question the fingerprint answers for derive grouping is "is this the
|
# note 2917 the derive grouping no longer reads CSS bodies at all (a
|
||||||
# same rule under another name?" — .closed-msg / .error-block /
|
# class is grouped by name only), so for CSS the fingerprint is the
|
||||||
# .success-msg with identical bodies are one dup group, not three
|
# recheck identity — "did this rule's body change since it was
|
||||||
# lonely rows. Sym blocks keep their signature line in the hash.
|
# judged?" — and nothing more. The shape of the hash is kept as-is on
|
||||||
|
# purpose: changing it would flip every judged CSS row to recheck on
|
||||||
|
# the next sync. Sym blocks keep their signature line in the hash.
|
||||||
if kind == "css":
|
if kind == "css":
|
||||||
# One-line rules (`.x { color: red; }`) carry their declarations on
|
# One-line rules (`.x { color: red; }`) carry their declarations on
|
||||||
# the selector line itself; a block that is only the selector plus
|
# the selector line itself; a block that is only the selector plus
|
||||||
@@ -202,6 +216,14 @@ def extract_definitions(text: str) -> list[Definition]:
|
|||||||
hashed = head + block[1:]
|
hashed = head + block[1:]
|
||||||
if not any(x.strip() for x in hashed):
|
if not any(x.strip() for x in hashed):
|
||||||
hashed = block
|
hashed = block
|
||||||
|
# A SINGLE declaration is not a shape (#2903): `color: var(--fs-
|
||||||
|
# text-tertiary)` under .text-muted, .task-mark and .pin-badge-auto
|
||||||
|
# is three meanings sharing one line, not three copies of one
|
||||||
|
# rule. Keep the selector in the hash for one-liners; two
|
||||||
|
# declarations and up stay selector-agnostic. (Moot for grouping
|
||||||
|
# since note 2917, kept for fingerprint stability — see above.)
|
||||||
|
elif _declaration_count(hashed) < 2:
|
||||||
|
hashed = block
|
||||||
else:
|
else:
|
||||||
hashed = block
|
hashed = block
|
||||||
out.append(Definition(
|
out.append(Definition(
|
||||||
@@ -247,6 +269,142 @@ def scoped_definitions(path: str, text: str, defs: list[Definition]) -> set[tupl
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- template class references: the CSS consumer map (milestone 302) ---------
|
||||||
|
|
||||||
|
# Files whose MARKUP can consume a class. Styling consumers are templates —
|
||||||
|
# `querySelector('.x')` / classList in scripts are deliberately not read in
|
||||||
|
# v1 (note 2917: watch CSS by name, by recipe, by token and by what uses it;
|
||||||
|
# "what uses it" is the template).
|
||||||
|
_TEMPLATE_SUFFIXES = (
|
||||||
|
".vue", ".html", ".htm", ".jsx", ".tsx", ".js", ".ts", ".svelte", ".astro",
|
||||||
|
)
|
||||||
|
_CLASS_TOKEN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$")
|
||||||
|
# Static: class="a b" / class='a b' / className="a b". The lookbehind keeps
|
||||||
|
# `:class=`, `v-bind:class=`, `data-class=` and `headerClass=` out of the
|
||||||
|
# static form (the Vue/React dynamic forms are read below; the others are
|
||||||
|
# not class attributes).
|
||||||
|
_STATIC_CLASS_RE = re.compile(
|
||||||
|
r"""(?<![:\w.-])(?:class|className)\s*=\s*(?:"([^"]*)"|'([^']*)')"""
|
||||||
|
)
|
||||||
|
# Dynamic: Vue `:class="…"` / `v-bind:class="…"`, React `className={…}` (one
|
||||||
|
# level of nested braces — an object literal inside the expression).
|
||||||
|
_DYNAMIC_CLASS_RE = re.compile(
|
||||||
|
r""":class\s*=\s*(?:"([^"]*)"|'([^']*)')"""
|
||||||
|
r"""|(?<![:\w.-])className\s*=\s*\{((?:[^{}]|\{[^{}]*\})*)\}"""
|
||||||
|
)
|
||||||
|
# Svelte's directive form: class:active={cond}.
|
||||||
|
_SVELTE_CLASS_RE = re.compile(r"(?<![:\w.-])class:([A-Za-z_][A-Za-z0-9_-]*)\s*=")
|
||||||
|
# Transition classes are applied by the FRAMEWORK, never written in markup:
|
||||||
|
# <Transition name="toast"> makes Vue add .toast-enter-active et al at
|
||||||
|
# runtime, and React's <CSSTransition classNames="fade"> does the same. A
|
||||||
|
# reader of `class=` attributes alone therefore calls every one of those
|
||||||
|
# rules unused, which is a false positive no amount of care in the
|
||||||
|
# stylesheet can avoid (#2970). A dynamic `:name="…"` stays unknowable.
|
||||||
|
_TRANSITION_NAME_RE = re.compile(
|
||||||
|
r"""<\s*[Tt]ransition(?:-[Gg]roup|Group)?\b[^>]*?(?<![:\w.-])name\s*=\s*"""
|
||||||
|
r"""(?:"([^"]*)"|'([^']*)')"""
|
||||||
|
r"""|(?<![:\w.-])classNames\s*=\s*(?:"([^"]*)"|'([^']*)')"""
|
||||||
|
)
|
||||||
|
# The union of what Vue 3, Vue 2 and React CSSTransition generate. Naming a
|
||||||
|
# class that no rule defines costs nothing — it resolves to no row — so the
|
||||||
|
# union is safer than guessing the framework from the file.
|
||||||
|
_TRANSITION_SUFFIXES = (
|
||||||
|
"-enter", "-enter-from", "-enter-active", "-enter-to", "-enter-done",
|
||||||
|
"-leave", "-leave-from", "-leave-active", "-leave-to",
|
||||||
|
"-exit", "-exit-active", "-exit-done",
|
||||||
|
"-appear", "-appear-from", "-appear-active", "-appear-to", "-appear-done",
|
||||||
|
"-move",
|
||||||
|
)
|
||||||
|
# A name built by concatenation — `status-${s}`, 'pri-' + p, class="c-{{ v }}"
|
||||||
|
# — leaves its static head behind once the hole is blanked. That head is a
|
||||||
|
# PREFIX reference, spelled `status-*`: "*" cannot occur in a class token, so
|
||||||
|
# the marker rides the plain token dict without a schema change. Needs a real
|
||||||
|
# name before the separator; `a-` or a bare `-` says nothing worth matching.
|
||||||
|
_PREFIX_MIN_STEM = 2
|
||||||
|
PREFIX_MARK = "*"
|
||||||
|
# Inside a dynamic expression: string literals (ternary arms, array items,
|
||||||
|
# quoted object keys) and the bare keys of object literals.
|
||||||
|
_STR_LIT_RE = re.compile(r"""'([^'\\]*)'|"([^"\\]*)"|`([^`]*)`""")
|
||||||
|
_OBJ_SPAN_RE = re.compile(r"\{([^{}]*)\}")
|
||||||
|
_OBJ_KEY_RE = re.compile(r"(?:^|[{,\s])([A-Za-z_][A-Za-z0-9_-]*)\s*:(?!:)")
|
||||||
|
_TEMPLATE_HOLE_RE = re.compile(r"\$\{[^}]*\}")
|
||||||
|
# A server-side / mustache interpolation inside a static value (`{{ cls }}`,
|
||||||
|
# `{% if %}`): unknowable at read time, contributes no token.
|
||||||
|
_MUSTACHE_RE = re.compile(r"\{[{%][^}]*[}%]\}")
|
||||||
|
|
||||||
|
|
||||||
|
def _class_tokens(value: str) -> list[str]:
|
||||||
|
"""The class tokens of a static attribute value: whitespace-split, only
|
||||||
|
well-formed names. An interpolation (`{{ cls }}`, `${cls}`) is blanked
|
||||||
|
before the split, so a name built around one leaves its static head —
|
||||||
|
`status-` from `status-{{ s }}` — which is emitted as the prefix
|
||||||
|
reference `status-*` rather than as a class nothing is called."""
|
||||||
|
out: list[str] = []
|
||||||
|
for t in _MUSTACHE_RE.sub(" ", value).split():
|
||||||
|
if not _CLASS_TOKEN_RE.match(t):
|
||||||
|
continue
|
||||||
|
if t.endswith(("-", "_")):
|
||||||
|
if len(t.rstrip("-_")) >= _PREFIX_MIN_STEM:
|
||||||
|
out.append(t + PREFIX_MARK)
|
||||||
|
continue
|
||||||
|
out.append(t)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _dynamic_class_tokens(expr: str) -> list[str]:
|
||||||
|
"""Class tokens named by a dynamic class expression: every string
|
||||||
|
literal's tokens (a template literal's static text only — its `${…}`
|
||||||
|
holes are unknowable) and the bare keys of object literals. Bare
|
||||||
|
identifiers elsewhere (`cond ? clsA : clsB`) are variables, not names."""
|
||||||
|
out: list[str] = []
|
||||||
|
for m in _STR_LIT_RE.finditer(expr):
|
||||||
|
literal = m.group(1) if m.group(1) is not None else (
|
||||||
|
m.group(2) if m.group(2) is not None else m.group(3)
|
||||||
|
)
|
||||||
|
if m.group(3) is not None:
|
||||||
|
literal = _TEMPLATE_HOLE_RE.sub(" ", literal)
|
||||||
|
out.extend(_class_tokens(literal))
|
||||||
|
for span in _OBJ_SPAN_RE.finditer(expr):
|
||||||
|
# Quoted keys were read as literals above; bare keys here.
|
||||||
|
body = _STR_LIT_RE.sub(" ", span.group(1))
|
||||||
|
out.extend(k for k in _OBJ_KEY_RE.findall(body) if _CLASS_TOKEN_RE.match(k))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def class_references(path: str, text: str) -> dict[str, int]:
|
||||||
|
"""class token → how many times this file's markup names it. Empty for
|
||||||
|
files that carry no markup (by suffix). Reads the static `class=` /
|
||||||
|
`className=` attributes, the Vue and React dynamic forms and Svelte's
|
||||||
|
`class:x` directive; never a CSS selector (`.x {` is a definition, read
|
||||||
|
by extract_definitions) and never a script's `querySelector('.x')`.
|
||||||
|
|
||||||
|
Two forms name classes without spelling them out, and both are read
|
||||||
|
(#2970): a transition `name=` stands for every class the framework
|
||||||
|
generates from it, and a concatenated name contributes the prefix
|
||||||
|
reference `head-*` — which resolve_consumers matches against every row
|
||||||
|
whose symbol starts with `head-`."""
|
||||||
|
if not (path or "").lower().endswith(_TEMPLATE_SUFFIXES):
|
||||||
|
return {}
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
|
||||||
|
def bump(tokens: list[str]) -> None:
|
||||||
|
for t in tokens:
|
||||||
|
counts[t] = counts.get(t, 0) + 1
|
||||||
|
|
||||||
|
for m in _STATIC_CLASS_RE.finditer(text):
|
||||||
|
bump(_class_tokens(m.group(1) if m.group(1) is not None else m.group(2)))
|
||||||
|
for m in _DYNAMIC_CLASS_RE.finditer(text):
|
||||||
|
expr = next((g for g in m.groups() if g is not None), "")
|
||||||
|
bump(_dynamic_class_tokens(expr))
|
||||||
|
bump([m.group(1) for m in _SVELTE_CLASS_RE.finditer(text)])
|
||||||
|
for m in _TRANSITION_NAME_RE.finditer(text):
|
||||||
|
name = next((g for g in m.groups() if g is not None), "").strip()
|
||||||
|
if not _CLASS_TOKEN_RE.match(name):
|
||||||
|
continue
|
||||||
|
bump([name + suffix for suffix in _TRANSITION_SUFFIXES])
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
def extract_shapes(text: str) -> list[tuple[str, str]]:
|
def extract_shapes(text: str) -> list[tuple[str, str]]:
|
||||||
"""Every (kind, name) this text DEFINES — kind is "css" or "sym".
|
"""Every (kind, name) this text DEFINES — kind is "css" or "sym".
|
||||||
|
|
||||||
@@ -285,14 +443,31 @@ def shapes_from_archive(blob: bytes) -> list[tuple[str, str, str]]:
|
|||||||
return [(d.path, d.kind, d.name) for d in definitions_from_archive(blob)]
|
return [(d.path, d.kind, d.name) for d in definitions_from_archive(blob)]
|
||||||
|
|
||||||
|
|
||||||
|
class ArchiveScan(NamedTuple):
|
||||||
|
"""One walk of a repo tarball: what each file DEFINES (the ledger rows)
|
||||||
|
and which class names each file's markup REFERENCES (the CSS consumer
|
||||||
|
map, milestone 302) — read together because the bodies are in hand once."""
|
||||||
|
|
||||||
|
definitions: list[ArchiveShape]
|
||||||
|
references: dict[str, dict[str, int]] # path → class token → count
|
||||||
|
|
||||||
|
|
||||||
def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
|
def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
|
||||||
"""Every definition in a repo tarball, with its fingerprint and body.
|
"""Every definition in a repo tarball, with its fingerprint and body —
|
||||||
|
the definitions half of scan_archive."""
|
||||||
|
return scan_archive(blob).definitions
|
||||||
|
|
||||||
|
|
||||||
|
def scan_archive(blob: bytes) -> ArchiveScan:
|
||||||
|
"""Every definition in a repo tarball, with its fingerprint and body,
|
||||||
|
plus each template-bearing file's class references.
|
||||||
|
|
||||||
Forge archives wrap content in a single top-level directory (repo-ref/);
|
Forge archives wrap content in a single top-level directory (repo-ref/);
|
||||||
that component is stripped so paths match recorded snippet locations,
|
that component is stripped so paths match recorded snippet locations,
|
||||||
which are repo-relative. Non-UTF-8 files are binaries and skipped.
|
which are repo-relative. Non-UTF-8 files are binaries and skipped.
|
||||||
"""
|
"""
|
||||||
shapes: list[ArchiveShape] = []
|
shapes: list[ArchiveShape] = []
|
||||||
|
references: dict[str, dict[str, int]] = {}
|
||||||
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
|
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
|
||||||
for member in tar:
|
for member in tar:
|
||||||
if not member.isfile() or "/" not in member.name:
|
if not member.isfile() or "/" not in member.name:
|
||||||
@@ -316,7 +491,10 @@ def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
|
|||||||
)
|
)
|
||||||
for d in defs
|
for d in defs
|
||||||
)
|
)
|
||||||
return shapes
|
refs = class_references(path, text)
|
||||||
|
if refs:
|
||||||
|
references[path] = refs
|
||||||
|
return ArchiveScan(shapes, references)
|
||||||
|
|
||||||
|
|
||||||
# --- matching shapes against recorded locations ------------------------------
|
# --- matching shapes against recorded locations ------------------------------
|
||||||
@@ -428,7 +606,8 @@ async def compute_coverage(
|
|||||||
# The binding's own ref when it names one (#2873: a dev-first project
|
# The binding's own ref when it names one (#2873: a dev-first project
|
||||||
# has its ledger follow dev), else the forge's default branch.
|
# has its ledger follow dev), else the forge's default branch.
|
||||||
ref = binding.ref or await forge.default_branch(api_repo)
|
ref = binding.ref or await forge.default_branch(api_repo)
|
||||||
definitions = definitions_from_archive(await forge.archive(api_repo, ref))
|
scan = scan_archive(await forge.archive(api_repo, ref))
|
||||||
|
definitions = scan.definitions
|
||||||
# The head commit is provenance sugar on the ledger rows; failing to
|
# The head commit is provenance sugar on the ledger rows; failing to
|
||||||
# learn it must not fail the sync — the ref names the point well
|
# learn it must not fail the sync — the ref names the point well
|
||||||
# enough and the row timestamps carry the when.
|
# enough and the row timestamps carry the when.
|
||||||
@@ -440,6 +619,13 @@ async def compute_coverage(
|
|||||||
project_id, key, definitions, seen_marker=marker
|
project_id, key, definitions, seen_marker=marker
|
||||||
)
|
)
|
||||||
served.append((key, ref))
|
served.append((key, ref))
|
||||||
|
# The CSS consumer map (milestone 302) rides the same archive: which
|
||||||
|
# files' markup names each class. Mechanical and recomputable, so it
|
||||||
|
# must not be able to fail the refresh either.
|
||||||
|
try:
|
||||||
|
await shape_ledger.sync_repo_consumers(project_id, key, scan.references)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("consumer map sync failed for %s", key, exc_info=True)
|
||||||
# Propose while the bodies are in hand — the one moment they exist.
|
# Propose while the bodies are in hand — the one moment they exist.
|
||||||
# Canonical marking below only touches rows the proposer leaves
|
# Canonical marking below only touches rows the proposer leaves
|
||||||
# alone (a canon's own location never gets a proposal), so the order
|
# alone (a canon's own location never gets a proposal), so the order
|
||||||
@@ -462,15 +648,20 @@ async def compute_coverage(
|
|||||||
await shape_ledger.apply_derive_groups(project_id)
|
await shape_ledger.apply_derive_groups(project_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("derive-first grouping failed", exc_info=True)
|
logger.warning("derive-first grouping failed", exc_info=True)
|
||||||
# The button-B pass (#2793): shapes new since the PREVIOUS computation,
|
# "Since the previous computation" — the cache's stamp. A first seed has
|
||||||
# where a canon dominates. The previous computation's stamp is the cache;
|
# none, so nothing is new then. Read once; two passes use it: the
|
||||||
# a first seed has none, so it flags nothing (everything is new then).
|
# button-B flag (#2793) and the derive-new drift count (#2899).
|
||||||
|
since = None
|
||||||
try:
|
try:
|
||||||
previous = await get_setting(user_id, f"{_CACHE_KEY_PREFIX}{project_id}")
|
previous = await get_setting(user_id, f"{_CACHE_KEY_PREFIX}{project_id}")
|
||||||
since = None
|
|
||||||
if previous:
|
if previous:
|
||||||
stamp = (json.loads(previous) or {}).get("computed_at")
|
stamp = (json.loads(previous) or {}).get("computed_at")
|
||||||
since = datetime.fromisoformat(stamp) if stamp else None
|
since = datetime.fromisoformat(stamp) if stamp else None
|
||||||
|
except Exception:
|
||||||
|
logger.warning("previous coverage stamp unreadable", exc_info=True)
|
||||||
|
# The button-B pass (#2793): shapes new since the PREVIOUS computation,
|
||||||
|
# where a canon dominates.
|
||||||
|
try:
|
||||||
await shape_ledger.flag_divergence(project_id, since=since)
|
await shape_ledger.flag_divergence(project_id, since=since)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("divergence pass failed", exc_info=True)
|
logger.warning("divergence pass failed", exc_info=True)
|
||||||
@@ -489,8 +680,25 @@ async def compute_coverage(
|
|||||||
agg["accounted"] += row.status != "unclassified"
|
agg["accounted"] += row.status != "unclassified"
|
||||||
|
|
||||||
unclassified = counts.pop("unclassified")
|
unclassified = counts.pop("unclassified")
|
||||||
proposals = shape_ledger.proposal_summary(rows)
|
# The CSS consumer map's readout (milestone 302): which files render each
|
||||||
|
# css row — on the derive groups (a shared recipe vs a scoped one is a
|
||||||
|
# count), and the negative space: css rules no template names. "Unused"
|
||||||
|
# is measured only where the map has evidence of templates at all (one
|
||||||
|
# edge somewhere); a repo of bare stylesheets is "not measured", not
|
||||||
|
# "all unused".
|
||||||
|
css_rows = [r for r in rows if r.kind == "css"]
|
||||||
|
consumer_paths: dict[int, list[str]] = {}
|
||||||
|
unused_css = None
|
||||||
|
try:
|
||||||
|
edges = await shape_ledger.consumers_of([r.id for r in css_rows])
|
||||||
|
consumer_paths = {sid: [e.path for e in es] for sid, es in edges.items()}
|
||||||
|
if consumer_paths:
|
||||||
|
unused_css = sum(1 for r in css_rows if r.id not in consumer_paths)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("consumer map read failed", exc_info=True)
|
||||||
|
proposals = shape_ledger.proposal_summary(rows, consumer_paths=consumer_paths)
|
||||||
divergence = shape_ledger.divergence_summary(rows)
|
divergence = shape_ledger.divergence_summary(rows)
|
||||||
|
derive_new = shape_ledger.derive_new_summary(rows, since=since)
|
||||||
return {
|
return {
|
||||||
"total": len(rows),
|
"total": len(rows),
|
||||||
"accounted": len(rows) - unclassified,
|
"accounted": len(rows) - unclassified,
|
||||||
@@ -501,6 +709,13 @@ async def compute_coverage(
|
|||||||
"proposed": proposals["proposed"],
|
"proposed": proposals["proposed"],
|
||||||
"derive_groups": proposals["derive_groups"],
|
"derive_groups": proposals["derive_groups"],
|
||||||
"top_canon": proposals.get("top_canon"),
|
"top_canon": proposals.get("top_canon"),
|
||||||
|
# Drift since the previous refresh (#2899): copies that joined a
|
||||||
|
# duplicate family — what the arrival line names so drift is noticed
|
||||||
|
# on entering, not found by an audit.
|
||||||
|
"derive_new": derive_new,
|
||||||
|
# The consumer map's negative space (milestone 302): live css rules
|
||||||
|
# no template names — None when the map has no evidence of templates.
|
||||||
|
"unused_css": unused_css,
|
||||||
"proposer": proposer_stats,
|
"proposer": proposer_stats,
|
||||||
# The divergence readout (#2793): button B where button A is canon,
|
# The divergence readout (#2793): button B where button A is canon,
|
||||||
# and judged shapes whose bodies moved since they were judged.
|
# and judged shapes whose bodies moved since they were judged.
|
||||||
@@ -648,29 +863,55 @@ def coverage_line(coverage: dict) -> str:
|
|||||||
line += f" — {breakdown}"
|
line += f" — {breakdown}"
|
||||||
line += f" (estimate{', computed ' + day if day else ''})"
|
line += f" (estimate{', computed ' + day if day else ''})"
|
||||||
unclassified = coverage.get("unclassified", 0)
|
unclassified = coverage.get("unclassified", 0)
|
||||||
|
# The standing work, built whatever the todo count (#2899). Since the
|
||||||
|
# scoped bucket (#2869) a ledger can read 100% accounted and still carry
|
||||||
|
# derive groups, proposals and divergence; gating this block on
|
||||||
|
# `unclassified > 0` is how 439 derive rows went unmentioned.
|
||||||
|
standing = []
|
||||||
|
if coverage.get("proposed"):
|
||||||
|
standing.append(f"{coverage['proposed']} proposed")
|
||||||
|
n_groups = len(coverage.get("derive_groups") or [])
|
||||||
|
if n_groups:
|
||||||
|
standing.append(f"{n_groups} derive group{'s' if n_groups != 1 else ''}")
|
||||||
|
# Drift since the previous refresh: copies that joined a family, the
|
||||||
|
# first one named — the sentence the arrival moment exists to say.
|
||||||
|
new = coverage.get("derive_new") or {}
|
||||||
|
if new.get("count"):
|
||||||
|
n = new["count"]
|
||||||
|
first_new = (new.get("examples") or [{}])[0]
|
||||||
|
where = (
|
||||||
|
f": {first_new['label']} in {first_new['path']}"
|
||||||
|
if first_new.get("label") and first_new.get("path") else ""
|
||||||
|
)
|
||||||
|
standing.append(f"+{n} new cop{'y' if n == 1 else 'ies'} since last refresh{where}")
|
||||||
|
if coverage.get("divergent"):
|
||||||
|
standing.append(f"{coverage['divergent']} DIVERGENT")
|
||||||
|
# The next action, on the line (#2874): the canon with the biggest
|
||||||
|
# queue to confirm, and the widest body-identical copy to consolidate.
|
||||||
|
top = coverage.get("top_canon") or {}
|
||||||
|
if top.get("snippet_id"):
|
||||||
|
standing.append(f"top canon #{top['snippet_id']} ×{top.get('count', 0)}")
|
||||||
|
first = (coverage.get("derive_groups") or [{}])[0]
|
||||||
|
if first.get("label") and first.get("files"):
|
||||||
|
top_copy = f"top copy {first['label']} ×{first['files']} files"
|
||||||
|
# A css family says what renders it (milestone 302): the count that
|
||||||
|
# tells a shared recipe from a scoped convention.
|
||||||
|
if "consumers" in first:
|
||||||
|
n_t = (first.get("consumers") or {}).get("count", 0)
|
||||||
|
top_copy += f" · used by {n_t} template{'s' if n_t != 1 else ''}"
|
||||||
|
standing.append(top_copy)
|
||||||
|
if coverage.get("unused_css"):
|
||||||
|
n_u = coverage["unused_css"]
|
||||||
|
standing.append(f"{n_u} unused class{'es' if n_u != 1 else ''}")
|
||||||
if unclassified:
|
if unclassified:
|
||||||
line += f"; {unclassified} unclassified"
|
line += f"; {unclassified} unclassified"
|
||||||
standing = []
|
|
||||||
if coverage.get("proposed"):
|
|
||||||
standing.append(f"{coverage['proposed']} proposed")
|
|
||||||
n_groups = len(coverage.get("derive_groups") or [])
|
|
||||||
if n_groups:
|
|
||||||
standing.append(f"{n_groups} derive group{'s' if n_groups != 1 else ''}")
|
|
||||||
if coverage.get("divergent"):
|
|
||||||
standing.append(f"{coverage['divergent']} DIVERGENT")
|
|
||||||
# The next action, on the line (#2874): the canon with the biggest
|
|
||||||
# queue to confirm, and the widest body-identical copy to consolidate.
|
|
||||||
top = coverage.get("top_canon") or {}
|
|
||||||
if top.get("snippet_id"):
|
|
||||||
standing.append(f"top canon #{top['snippet_id']} ×{top.get('count', 0)}")
|
|
||||||
first = (coverage.get("derive_groups") or [{}])[0]
|
|
||||||
if first.get("label") and first.get("files"):
|
|
||||||
standing.append(f"top copy {first['label']} ×{first['files']} files")
|
|
||||||
if standing:
|
if standing:
|
||||||
line += f" ({', '.join(standing)})"
|
line += f" ({', '.join(standing)})"
|
||||||
gaps = [g["dir"] for g in coverage.get("largest_gaps") or []]
|
gaps = [g["dir"] for g in coverage.get("largest_gaps") or []]
|
||||||
if gaps:
|
if gaps:
|
||||||
line += ", largest: " + ", ".join(gaps)
|
line += ", largest: " + ", ".join(gaps)
|
||||||
|
elif standing:
|
||||||
|
line += f"; standing: {', '.join(standing)}"
|
||||||
if coverage.get("recheck"):
|
if coverage.get("recheck"):
|
||||||
line += f"; {coverage['recheck']} judged shape{'s' if coverage['recheck'] != 1 else ''} changed since judged — recheck"
|
line += f"; {coverage['recheck']} judged shape{'s' if coverage['recheck'] != 1 else ''} changed since judged — recheck"
|
||||||
return line
|
return line
|
||||||
|
|||||||
@@ -16,13 +16,18 @@ import os
|
|||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import delete, or_, select
|
from sqlalchemy import delete, or_, select
|
||||||
|
|
||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
from scribe.models.embedding import NoteEmbedding
|
from scribe.models.embedding import NoteEmbedding, RuleEmbedding
|
||||||
from scribe.models.note import Note
|
from scribe.models.note import Note
|
||||||
from scribe.services.access import notes_visibility_clause
|
from scribe.services.access import notes_visibility_clause
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # resolves the Rule forward ref without importing at runtime
|
||||||
|
from scribe.models.rulebook import Rule
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Minimum cosine similarity to include a note in context results.
|
# Minimum cosine similarity to include a note in context results.
|
||||||
@@ -612,3 +617,193 @@ async def backfill_note_embeddings() -> None:
|
|||||||
await asyncio.sleep(0.05) # gentle pacing
|
await asyncio.sleep(0.05) # gentle pacing
|
||||||
|
|
||||||
logger.info("Embedding backfill complete: %d/%d notes embedded", success, len(notes_to_embed))
|
logger.info("Embedding backfill complete: %d/%d notes embedded", success, len(notes_to_embed))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Rules (milestone 307, note 3026) ────────────────────────────────────
|
||||||
|
|
||||||
|
def rule_document(
|
||||||
|
title: str | None, statement: str | None, when_to_apply: str | None,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""The (title, body) a rule is EMBEDDED as — trigger first, `why` never.
|
||||||
|
|
||||||
|
Both halves of this are measured, not guessed (note 2485). That pass found
|
||||||
|
the snippet was the only sharp record in the corpus — a 0.153 top-to-second
|
||||||
|
gap against 0.010–0.023 for everything else — and that the cause was its
|
||||||
|
SHAPE: `{name} — {when_to_use}` as the title and `**When to use:** …`
|
||||||
|
repeated in the body, so purpose appears twice in a short document and
|
||||||
|
dominates the vector. This mirrors that exactly.
|
||||||
|
|
||||||
|
And it excludes `why` on the same evidence. `why` is dated incident
|
||||||
|
narrative — rule 46's runs to 4,300 characters of it — and long,
|
||||||
|
multi-topic prose is precisely what made sixteen dev-logs mutually
|
||||||
|
indistinguishable: the average lands on the centroid of "development",
|
||||||
|
which every one of them shares. Adding `why` would not give the vector more
|
||||||
|
to work with; it would give every rule the same thing to work with.
|
||||||
|
|
||||||
|
A rule with no trigger yet degrades to title + statement. It still embeds,
|
||||||
|
just less sharply — which is an argument for backfilling triggers, not an
|
||||||
|
argument for padding the document with whatever text is lying around.
|
||||||
|
"""
|
||||||
|
trigger = (when_to_apply or "").strip()
|
||||||
|
name = (title or "").strip()
|
||||||
|
body = (statement or "").strip()
|
||||||
|
if not trigger:
|
||||||
|
return name or None, body or None
|
||||||
|
return (
|
||||||
|
f"{name} — {trigger}" if name else trigger,
|
||||||
|
f"When to apply: {trigger}\n\n{body}" if body else f"When to apply: {trigger}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_rule_embedding(
|
||||||
|
rule_id: int, title: str | None, statement: str | None,
|
||||||
|
when_to_apply: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Chunk, embed and persist a rule's vectors. Safe to fire-and-forget.
|
||||||
|
|
||||||
|
The note twin's contract, for the same reasons: the document is built HERE
|
||||||
|
so the write path, the backfill and any re-embed share one definition, and
|
||||||
|
replacement is atomic per rule so a concurrent read sees the old chunk set
|
||||||
|
or the new one, never a mixture.
|
||||||
|
"""
|
||||||
|
doc_title, doc_body = rule_document(title, statement, when_to_apply)
|
||||||
|
chunks = chunk_document(doc_title, doc_body)
|
||||||
|
try:
|
||||||
|
if not chunks:
|
||||||
|
async with async_session() as session:
|
||||||
|
await session.execute(
|
||||||
|
delete(RuleEmbedding).where(RuleEmbedding.rule_id == rule_id)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to clear embedding for rule %d", rule_id, exc_info=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
vectors = await get_embeddings(chunks)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Skipping embedding for rule %d — embedder unavailable", rule_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
await session.execute(
|
||||||
|
delete(RuleEmbedding).where(RuleEmbedding.rule_id == rule_id)
|
||||||
|
)
|
||||||
|
for index, (chunk, vector) in enumerate(zip(chunks, vectors)):
|
||||||
|
session.add(
|
||||||
|
RuleEmbedding(
|
||||||
|
rule_id=rule_id,
|
||||||
|
chunk_index=index,
|
||||||
|
embedding=vector,
|
||||||
|
chunk_text=chunk,
|
||||||
|
chunker_version=CHUNKER_VERSION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to persist embedding for rule %d", rule_id, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def semantic_search_rules(
|
||||||
|
user_id: int,
|
||||||
|
query: str,
|
||||||
|
limit: int = 5,
|
||||||
|
threshold: float = _SIMILARITY_THRESHOLD,
|
||||||
|
tier: str | None = None,
|
||||||
|
) -> list[tuple[float, "Rule"]]:
|
||||||
|
"""Return up to *limit* (score, rule) pairs most relevant to *query*.
|
||||||
|
|
||||||
|
Scoped by OWNERSHIP — a rule is the caller's if they own its rulebook or
|
||||||
|
its project. Deliberately not filtered to what currently BINDS a given
|
||||||
|
project: this answers "is there a rule about this", which a person asking
|
||||||
|
wants answered across their whole rulebook. Deciding which rules bind where
|
||||||
|
is the surfacing question, and it has its own machinery
|
||||||
|
(get_applicable_rules) rather than a second, subtly different copy here.
|
||||||
|
|
||||||
|
`tier` narrows to one tier. The write-path hint passes "conditional",
|
||||||
|
because an always-on rule is ALREADY in the session — surfacing it again as
|
||||||
|
a suggestion is pure noise, and noise on a hint that fires on every write
|
||||||
|
is how a hint gets ignored.
|
||||||
|
|
||||||
|
Collapses to best-chunk-per-rule like the note search, so a long rule split
|
||||||
|
across chunks competes once rather than crowding the results with itself.
|
||||||
|
|
||||||
|
Returns an empty list if the embedder is unavailable or on any error.
|
||||||
|
"""
|
||||||
|
from scribe.models.project import Project
|
||||||
|
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
|
||||||
|
|
||||||
|
if not query or not query.strip():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
query_vec = await get_embedding(query)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Rule search skipped — embedder unavailable")
|
||||||
|
return []
|
||||||
|
|
||||||
|
max_distance = min(2.0, max(0.0, 1.0 - threshold))
|
||||||
|
distance = RuleEmbedding.embedding.cosine_distance(query_vec)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(Rule, distance.label("distance"))
|
||||||
|
.select_from(RuleEmbedding)
|
||||||
|
.join(Rule, RuleEmbedding.rule_id == Rule.id)
|
||||||
|
.outerjoin(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
||||||
|
.outerjoin(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||||
|
.outerjoin(Project, Rule.project_id == Project.id)
|
||||||
|
.where(
|
||||||
|
Rule.deleted_at.is_(None),
|
||||||
|
distance <= max_distance,
|
||||||
|
# topic_id XOR project_id, so exactly one arm can match.
|
||||||
|
or_(
|
||||||
|
Rulebook.owner_user_id == user_id,
|
||||||
|
Project.user_id == user_id,
|
||||||
|
),
|
||||||
|
*( [Rule.tier == tier] if tier else [] ),
|
||||||
|
)
|
||||||
|
# Overfetch so collapsing chunks to their best row still fills
|
||||||
|
# the page — the same reason the note search overfetches.
|
||||||
|
.order_by(distance)
|
||||||
|
.limit(limit * _CHUNK_OVERFETCH)
|
||||||
|
)).all()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Rule semantic search failed", exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
best: dict[int, tuple[float, object]] = {}
|
||||||
|
for rule, dist in rows:
|
||||||
|
score = 1.0 - float(dist)
|
||||||
|
if rule.id not in best or score > best[rule.id][0]:
|
||||||
|
best[rule.id] = (score, rule)
|
||||||
|
ranked = sorted(best.values(), key=lambda pair: pair[0], reverse=True)
|
||||||
|
return ranked[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
async def backfill_rule_embeddings() -> None:
|
||||||
|
"""Embed rules that have no current vectors. Runs at startup beside the
|
||||||
|
note backfill; a CHUNKER_VERSION bump re-embeds rather than wiping."""
|
||||||
|
from scribe.models.rulebook import Rule
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
current = select(RuleEmbedding.rule_id).where(
|
||||||
|
RuleEmbedding.chunker_version == CHUNKER_VERSION
|
||||||
|
)
|
||||||
|
stale = (await session.execute(
|
||||||
|
select(Rule.id, Rule.title, Rule.statement, Rule.when_to_apply)
|
||||||
|
.where(Rule.deleted_at.is_(None), Rule.id.notin_(current))
|
||||||
|
)).all()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Rule embedding backfill: failed to query rules", exc_info=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not stale:
|
||||||
|
logger.info("Rule embedding backfill: all rules current at chunker v%d", CHUNKER_VERSION)
|
||||||
|
return
|
||||||
|
logger.info("Rule embedding backfill: embedding %d rule(s)", len(stale))
|
||||||
|
for rule_id, title, statement, when_to_apply in stale:
|
||||||
|
await upsert_rule_embedding(rule_id, title, statement, when_to_apply)
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""Project inception — what a project was decided to inherit (milestone 297).
|
||||||
|
|
||||||
|
A project's inheritance is a decision, not a default. The record lives on
|
||||||
|
``projects.inception``::
|
||||||
|
|
||||||
|
{
|
||||||
|
"decided_at": "<iso>", "decided_by": <user id> | null,
|
||||||
|
"via": "mcp" | "ui" | "legacy",
|
||||||
|
"choices": {
|
||||||
|
"exclude_always_on_rulebooks": [rulebook ids],
|
||||||
|
"subscribe_rulebooks": [rulebook ids],
|
||||||
|
"design_system_id": <id> | null,
|
||||||
|
"seed_systems": bool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NULL = undecided → enter_project asks. ``legacy`` is the migration's stamp on
|
||||||
|
projects that existed before the step did (inherit-all / no design system /
|
||||||
|
no seed), so the ask fires only for projects created after this shipped.
|
||||||
|
|
||||||
|
The shape and its validator are pure; ``decide`` composes the existing
|
||||||
|
services — always-on exclusions, subscriptions, set_project_design_system,
|
||||||
|
the standard Systems seed — checks every target BEFORE touching anything,
|
||||||
|
applies the effects (each idempotent), and writes the record LAST, so a
|
||||||
|
half-applied decision is re-runnable rather than recorded as done.
|
||||||
|
``current_defaults`` is what the enter_project ask shows: what binds today
|
||||||
|
if nobody decides.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from scribe.models import async_session
|
||||||
|
from scribe.models.project import Project
|
||||||
|
from scribe.models.rulebook import Rulebook
|
||||||
|
|
||||||
|
INCEPTION_VIAS = ("mcp", "ui", "legacy")
|
||||||
|
CHOICE_KEYS = ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_id_list(value) -> bool:
|
||||||
|
return isinstance(value, list) and all(
|
||||||
|
isinstance(v, int) and not isinstance(v, bool) and v > 0 for v in value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_inception(choices) -> str | None:
|
||||||
|
"""The structural error an inception ``choices`` object would earn, or
|
||||||
|
None. Pure and checked BEFORE any effect is applied: a decision either
|
||||||
|
applies whole or errors whole (the StrictArgs lesson, #2709).
|
||||||
|
|
||||||
|
Accepts the four keys, each optional: two id lists (positive ints, no
|
||||||
|
duplicates between exclude and subscribe), ``design_system_id`` an int
|
||||||
|
or None, ``seed_systems`` a bool. Unknown keys are an error — a typo
|
||||||
|
must not become a silently ignored choice."""
|
||||||
|
if not isinstance(choices, dict):
|
||||||
|
return "choices must be an object"
|
||||||
|
unknown = sorted(set(choices) - set(CHOICE_KEYS))
|
||||||
|
if unknown:
|
||||||
|
return f"unknown inception choice(s): {', '.join(unknown)} (one of: {', '.join(CHOICE_KEYS)})"
|
||||||
|
excl = choices.get("exclude_always_on_rulebooks") or []
|
||||||
|
subs = choices.get("subscribe_rulebooks") or []
|
||||||
|
if not _is_id_list(excl):
|
||||||
|
return "exclude_always_on_rulebooks must be a list of rulebook ids"
|
||||||
|
if not _is_id_list(subs):
|
||||||
|
return "subscribe_rulebooks must be a list of rulebook ids"
|
||||||
|
both = sorted(set(excl) & set(subs))
|
||||||
|
if both:
|
||||||
|
return f"rulebook(s) {both} cannot be both excluded and subscribed"
|
||||||
|
ds = choices.get("design_system_id")
|
||||||
|
if ds is not None and (isinstance(ds, bool) or not isinstance(ds, int) or ds <= 0):
|
||||||
|
return "design_system_id must be a positive id or null"
|
||||||
|
seed = choices.get("seed_systems", False)
|
||||||
|
if not isinstance(seed, bool):
|
||||||
|
return "seed_systems must be true or false"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_choices(choices: dict | None) -> dict:
|
||||||
|
"""The four keys, always present, in canonical form — what gets stored
|
||||||
|
and what the UI/agent reads back. Call after validate_inception."""
|
||||||
|
choices = choices or {}
|
||||||
|
return {
|
||||||
|
"exclude_always_on_rulebooks": sorted(set(choices.get("exclude_always_on_rulebooks") or [])),
|
||||||
|
"subscribe_rulebooks": sorted(set(choices.get("subscribe_rulebooks") or [])),
|
||||||
|
"design_system_id": choices.get("design_system_id"),
|
||||||
|
"seed_systems": bool(choices.get("seed_systems", False)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_decided(project) -> bool:
|
||||||
|
"""A project is decided once its inception record exists (any via)."""
|
||||||
|
return bool(getattr(project, "inception", None))
|
||||||
|
|
||||||
|
|
||||||
|
async def current_defaults(user_id: int, project_id: int) -> dict:
|
||||||
|
"""What the project inherits if nobody decides — the ask's payload.
|
||||||
|
|
||||||
|
{always_on_rulebooks: [{id,title}], other_rulebooks: [{id,title}],
|
||||||
|
excluded_always_on: [...], subscribed_rulebooks: [...],
|
||||||
|
design_system_id, design_systems: [{id,title}], systems: <count>}.
|
||||||
|
Instance-agnostic: an install with no rulebooks / design systems shows
|
||||||
|
empty lists, and the ask says so rather than inventing a default.
|
||||||
|
"""
|
||||||
|
from scribe.services import design_systems as design_systems_svc
|
||||||
|
from scribe.services import projects as projects_svc
|
||||||
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
|
from scribe.services import systems as systems_svc
|
||||||
|
|
||||||
|
project = await projects_svc.get_project(user_id, project_id)
|
||||||
|
if project is None:
|
||||||
|
raise ValueError(f"project {project_id} not found")
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(Rulebook.id, Rulebook.title, Rulebook.always_on)
|
||||||
|
.where(Rulebook.owner_user_id == user_id, Rulebook.deleted_at.is_(None))
|
||||||
|
.order_by(Rulebook.title)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
applicable = await rulebooks_svc.get_applicable_rules(project_id, user_id, limit=1)
|
||||||
|
designs = await design_systems_svc.list_design_systems(user_id)
|
||||||
|
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
|
||||||
|
return {
|
||||||
|
"always_on_rulebooks": [{"id": i, "title": t} for i, t, on in rows if on],
|
||||||
|
"other_rulebooks": [{"id": i, "title": t} for i, t, on in rows if not on],
|
||||||
|
"excluded_always_on": applicable.get("excluded_always_on", []),
|
||||||
|
"subscribed_rulebooks": applicable.get("subscribed_rulebooks", []),
|
||||||
|
"design_system_id": project.design_system_id,
|
||||||
|
"design_systems": [{"id": d.id, "title": d.title} for d in designs],
|
||||||
|
"systems": len(systems),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_targets(user_id: int, choices: dict) -> None:
|
||||||
|
"""Every id a decision names must be the caller's (or readable) BEFORE any
|
||||||
|
effect lands — a decision applies whole or errors whole."""
|
||||||
|
from scribe.services import access
|
||||||
|
|
||||||
|
wanted = set(choices["exclude_always_on_rulebooks"]) | set(choices["subscribe_rulebooks"])
|
||||||
|
if wanted:
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(Rulebook.id, Rulebook.always_on).where(
|
||||||
|
Rulebook.id.in_(wanted),
|
||||||
|
Rulebook.owner_user_id == user_id,
|
||||||
|
Rulebook.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
found = {rid: on for rid, on in rows}
|
||||||
|
missing = sorted(wanted - set(found))
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"rulebook(s) {missing} not found (or not yours)")
|
||||||
|
not_always = sorted(r for r in choices["exclude_always_on_rulebooks"] if not found[r])
|
||||||
|
if not_always:
|
||||||
|
raise ValueError(
|
||||||
|
f"rulebook(s) {not_always} are not always-on — only always-on rulebooks "
|
||||||
|
"can be excluded; a subscribed rulebook is simply not subscribed"
|
||||||
|
)
|
||||||
|
ds = choices["design_system_id"]
|
||||||
|
if ds is not None and not await access.can_read_design_system(user_id, ds):
|
||||||
|
raise ValueError(f"design system {ds} not found (or not readable)")
|
||||||
|
|
||||||
|
|
||||||
|
async def decide(
|
||||||
|
user_id: int,
|
||||||
|
project_id: int,
|
||||||
|
*,
|
||||||
|
choices: dict | None,
|
||||||
|
via: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Record a project's inception decision and apply it (milestone 297).
|
||||||
|
|
||||||
|
Owner-only. Validates the choices (pure) and every target (owned /
|
||||||
|
readable) first; then, each idempotent: exclude the named always-on
|
||||||
|
rulebooks, subscribe the named rulebooks, point the project at the design
|
||||||
|
system (None = explicitly none), seed the standard Systems if asked and
|
||||||
|
the project has none; then write ``projects.inception`` LAST. Re-deciding
|
||||||
|
is additive for exclusions/subscriptions (nothing is silently dropped —
|
||||||
|
include/unsubscribe are explicit calls), replaces the design system, and
|
||||||
|
re-seeds nothing a project already has.
|
||||||
|
|
||||||
|
Returns {"inception": <record>, "effects": {excluded, subscribed,
|
||||||
|
design_system_id, systems_seeded}}.
|
||||||
|
"""
|
||||||
|
from scribe.services import design_systems as design_systems_svc
|
||||||
|
from scribe.services import projects as projects_svc
|
||||||
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
|
from scribe.services import systems as systems_svc
|
||||||
|
|
||||||
|
if via not in INCEPTION_VIAS or via == "legacy":
|
||||||
|
raise ValueError("via must be 'mcp' or 'ui' ('legacy' is the migration's stamp)")
|
||||||
|
error = validate_inception(choices or {})
|
||||||
|
if error:
|
||||||
|
raise ValueError(error)
|
||||||
|
choices = normalize_choices(choices)
|
||||||
|
project = await projects_svc.get_project(user_id, project_id) # owner-scoped
|
||||||
|
if project is None:
|
||||||
|
raise ValueError(f"project {project_id} not found (or not yours)")
|
||||||
|
await _check_targets(user_id, choices)
|
||||||
|
|
||||||
|
for rb in choices["exclude_always_on_rulebooks"]:
|
||||||
|
await rulebooks_svc.exclude_always_on_rulebook_for_project(project_id, rb, user_id)
|
||||||
|
for rb in choices["subscribe_rulebooks"]:
|
||||||
|
await rulebooks_svc.subscribe_project(project_id, rb, user_id)
|
||||||
|
if not await design_systems_svc.set_project_design_system(
|
||||||
|
user_id, project_id, choices["design_system_id"]
|
||||||
|
):
|
||||||
|
raise ValueError("could not set the design system (no write on the project?)")
|
||||||
|
seeded = (
|
||||||
|
await systems_svc.seed_standard_systems(user_id, project_id)
|
||||||
|
if choices["seed_systems"] else []
|
||||||
|
)
|
||||||
|
|
||||||
|
record = {
|
||||||
|
"decided_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"decided_by": user_id,
|
||||||
|
"via": via,
|
||||||
|
"choices": choices,
|
||||||
|
}
|
||||||
|
async with async_session() as session:
|
||||||
|
row = await session.get(Project, project_id)
|
||||||
|
row.inception = record
|
||||||
|
row.updated_at = datetime.now(timezone.utc)
|
||||||
|
await session.commit()
|
||||||
|
return {
|
||||||
|
"inception": record,
|
||||||
|
"effects": {
|
||||||
|
"excluded": choices["exclude_always_on_rulebooks"],
|
||||||
|
"subscribed": choices["subscribe_rulebooks"],
|
||||||
|
"design_system_id": choices["design_system_id"],
|
||||||
|
"systems_seeded": [sy.name for sy in seeded],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def inception_ask(user_id: int, project_id: int) -> dict:
|
||||||
|
"""The enter_project ask for an undecided project (milestone 297) — the
|
||||||
|
sibling of the systems-bootstrap ask (#2683): the project's OWN current
|
||||||
|
defaults, what to ask the operator, and the exact call that answers it.
|
||||||
|
Fail-open: a hint must never break the call it rides on."""
|
||||||
|
try:
|
||||||
|
defaults = await current_defaults(user_id, project_id)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
always = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["always_on_rulebooks"]) or "none"
|
||||||
|
others = ", ".join(f"{r['title']} (#{r['id']})" for r in defaults["other_rulebooks"]) or "none"
|
||||||
|
designs = ", ".join(f"{d['title']} (#{d['id']})" for d in defaults["design_systems"]) or "none"
|
||||||
|
return {
|
||||||
|
"defaults": defaults,
|
||||||
|
"ask": (
|
||||||
|
"This project has no inception decision: nobody has said what it "
|
||||||
|
f"inherits. Today, by default: always-on rulebooks binding it — {always}; "
|
||||||
|
f"rulebooks it could subscribe to — {others}; design system — "
|
||||||
|
f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} "
|
||||||
|
f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
|
||||||
|
"once: which always-on rulebooks to EXCLUDE here (default: none), which "
|
||||||
|
"rulebooks to subscribe, which design system (or none), and whether to seed "
|
||||||
|
"the standard starter Systems — then record the answers. This ask repeats on "
|
||||||
|
"every enter_project until a decision is recorded."
|
||||||
|
),
|
||||||
|
"call": (
|
||||||
|
f"decide_project_inception(project_id={project_id}, "
|
||||||
|
"exclude_always_on_rulebooks=[...], subscribe_rulebooks=[...], "
|
||||||
|
"design_system_id=<id | -1 for none>, seed_systems=<true|false>)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ from scribe.services import rulebooks as rulebooks_svc
|
|||||||
from scribe.services import shape_ledger as shape_ledger_svc
|
from scribe.services import shape_ledger as shape_ledger_svc
|
||||||
from scribe.services import snippets as snippets_svc
|
from scribe.services import snippets as snippets_svc
|
||||||
from scribe.services.access import label_shared_items, owner_names_for
|
from scribe.services.access import label_shared_items, owner_names_for
|
||||||
from scribe.services.embeddings import semantic_search_notes
|
from scribe.services.embeddings import semantic_search_notes, semantic_search_rules
|
||||||
from scribe.services.note_usage import record_surfaced
|
from scribe.services.note_usage import record_surfaced
|
||||||
from scribe.services.supersession import superseded_ids
|
from scribe.services.supersession import superseded_ids
|
||||||
from scribe.services.retrieval_telemetry import record_retrieval
|
from scribe.services.retrieval_telemetry import record_retrieval
|
||||||
@@ -706,6 +706,8 @@ async def build_write_path_hint(
|
|||||||
exclude_sync_ids: list[int] | None = None,
|
exclude_sync_ids: list[int] | None = None,
|
||||||
stamp_shapes: list[tuple[str, str]] | None = None,
|
stamp_shapes: list[tuple[str, str]] | None = None,
|
||||||
repo_key: str = "",
|
repo_key: str = "",
|
||||||
|
exclude_derive: list[str] | None = None,
|
||||||
|
exclude_rule_ids: list[int] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
"""Prior-art hint for the plugin's PreToolUse hook on Write/Edit.
|
||||||
|
|
||||||
@@ -765,7 +767,8 @@ async def build_write_path_hint(
|
|||||||
"""
|
"""
|
||||||
cfg = await get_writepath_config(user_id)
|
cfg = await get_writepath_config(user_id)
|
||||||
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg,
|
empty = {"context": "", "note_ids": [], "sync_note_ids": [], "config": cfg,
|
||||||
"stamped": [], "divergence": []}
|
"stamped": [], "divergence": [], "derive": [], "derive_keys": [],
|
||||||
|
"rule_ids": []}
|
||||||
path = (path or "").strip()
|
path = (path or "").strip()
|
||||||
if not cfg["enabled"] or not path:
|
if not cfg["enabled"] or not path:
|
||||||
return empty
|
return empty
|
||||||
@@ -935,7 +938,20 @@ async def build_write_path_hint(
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("write-time divergence check failed", exc_info=True)
|
logger.warning("write-time divergence check failed", exc_info=True)
|
||||||
if not synced and not menu and not stamped and not divergence:
|
# The in-band DERIVE check (#2900): the ledger's own knowledge of the
|
||||||
|
# names being written — a duplicate family with no canon, or a canon
|
||||||
|
# recorded elsewhere. This is the arm the by-name local grep could not
|
||||||
|
# be: it knows whether the other copies are canon or stray. Keyed per
|
||||||
|
# session (`exclude_derive`) so a family is named once, not per edit.
|
||||||
|
derive: list[dict] = []
|
||||||
|
if stamp_shapes and project_id:
|
||||||
|
try:
|
||||||
|
found = await shape_ledger_svc.write_time_derive(project_id, path, stamp_shapes)
|
||||||
|
skip = set(exclude_derive or [])
|
||||||
|
derive = [d for d in found if d.get("key") not in skip]
|
||||||
|
except Exception:
|
||||||
|
logger.warning("write-time derive check failed", exc_info=True)
|
||||||
|
if not synced and not menu and not stamped and not divergence and not derive:
|
||||||
return empty
|
return empty
|
||||||
|
|
||||||
owners = await owner_names_for({
|
owners = await owner_names_for({
|
||||||
@@ -1003,6 +1019,8 @@ async def build_write_path_hint(
|
|||||||
lines.append(_stamp_line(path, stamped))
|
lines.append(_stamp_line(path, stamped))
|
||||||
if divergence:
|
if divergence:
|
||||||
lines.append(_divergence_line(path, divergence))
|
lines.append(_divergence_line(path, divergence))
|
||||||
|
if derive:
|
||||||
|
lines.append(_derive_line(path, derive))
|
||||||
|
|
||||||
# Split by arm, which is the whole reason this table exists. The place arm
|
# Split by arm, which is the whole reason this table exists. The place arm
|
||||||
# carries no score and so has no home in retrieval_logs; before #2085 a
|
# carries no score and so has no home in retrieval_logs; before #2085 a
|
||||||
@@ -1020,6 +1038,50 @@ async def build_write_path_hint(
|
|||||||
for arm, ids in by_arm.items():
|
for arm, ids in by_arm.items():
|
||||||
record_surfaced(user_id=user_id, note_ids=ids, source=arm)
|
record_surfaced(user_id=user_id, note_ids=ids, source=arm)
|
||||||
|
|
||||||
|
# ── Standing rules that may apply here (milestone 307) ──────────────
|
||||||
|
#
|
||||||
|
# A SUGGESTION, not a binding surface, and the distinction is the design
|
||||||
|
# (D7): a rule BINDS by being tagged to an area the project works in,
|
||||||
|
# resolved deterministically at enter_project. This arm reaches for
|
||||||
|
# something weaker and still useful — a conditional rule whose trigger
|
||||||
|
# resembles what is being written, noticed at the moment it is relevant
|
||||||
|
# rather than by being resident in every session.
|
||||||
|
#
|
||||||
|
# CONDITIONAL ONLY. An always-on rule is already in the session; repeating
|
||||||
|
# it here would be noise, and noise on a hint that fires on every write is
|
||||||
|
# how a hint gets ignored.
|
||||||
|
#
|
||||||
|
# Fails open like every other arm: a rule hint must never break a write.
|
||||||
|
rule_ids: list[int] = []
|
||||||
|
try:
|
||||||
|
already = set(exclude_rule_ids or [])
|
||||||
|
hits = await semantic_search_rules(
|
||||||
|
user_id, code or path, limit=2,
|
||||||
|
threshold=cfg["threshold"], tier="conditional",
|
||||||
|
)
|
||||||
|
fresh = [(score, rule) for score, rule in hits if rule.id not in already]
|
||||||
|
for _score, rule in fresh:
|
||||||
|
trigger = (rule.when_to_apply or "").strip()
|
||||||
|
lines.append(
|
||||||
|
f"Standing rule that may apply here — \u201c{rule.title}\u201d"
|
||||||
|
+ (f" ({trigger})" if trigger else "")
|
||||||
|
+ f". Read it with get_rule({rule.id}) before deciding it "
|
||||||
|
"does not apply; it is not in this session's loaded set."
|
||||||
|
)
|
||||||
|
rule_ids.append(rule.id)
|
||||||
|
if fresh:
|
||||||
|
# retrieval_logs, NOT note_usage_events: that table's ids are
|
||||||
|
# remapped on a backup restore, so a rule id there would return
|
||||||
|
# attached to whatever note took that number. This one is never
|
||||||
|
# restored, and `source` already separates the surfaces.
|
||||||
|
record_retrieval(
|
||||||
|
user_id=user_id, source="write_path_rule", query=code or path,
|
||||||
|
threshold=cfg["threshold"], limit=2, project_id=project_id,
|
||||||
|
is_task=None, results=fresh,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("write-path rule arm failed", exc_info=True)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"context": "\n".join(lines),
|
"context": "\n".join(lines),
|
||||||
"note_ids": note_ids,
|
"note_ids": note_ids,
|
||||||
@@ -1027,9 +1089,63 @@ async def build_write_path_hint(
|
|||||||
"config": cfg,
|
"config": cfg,
|
||||||
"stamped": stamped,
|
"stamped": stamped,
|
||||||
"divergence": divergence,
|
"divergence": divergence,
|
||||||
|
"derive": derive,
|
||||||
|
"derive_keys": [d["key"] for d in derive],
|
||||||
|
"rule_ids": rule_ids,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_line(path: str, derive: list[dict]) -> str:
|
||||||
|
"""The ledger's word on the names being written (#2900): a duplicate
|
||||||
|
family to derive, or a canon to reuse — said at the write."""
|
||||||
|
parts = []
|
||||||
|
for d in derive:
|
||||||
|
if d.get("canon"):
|
||||||
|
c = d["canon"]
|
||||||
|
parts.append(
|
||||||
|
f"`{c['label']}` is canon — snippet #{c['snippet_id']} at `{c['path']}`; "
|
||||||
|
"pull it and reuse, don't redefine"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
f = d["family"]
|
||||||
|
files = ", ".join(f"`{x}`" for x in f.get("files") or [])
|
||||||
|
more = f.get("file_count", 0) - len(f.get("files") or [])
|
||||||
|
if more > 0:
|
||||||
|
files += f" +{more} more"
|
||||||
|
n = f.get("file_count", 0)
|
||||||
|
if f.get("identical"):
|
||||||
|
what = f"is a duplicate family with no canon — identical body in {n} other file(s)"
|
||||||
|
else:
|
||||||
|
# A name family: the same definition name living in several
|
||||||
|
# files. CSS is only ever grouped this way (note 2917) — a class
|
||||||
|
# is a recipe, and the recipe is what gets derived or dismissed.
|
||||||
|
what = f"is a repeated name with no canon — defined in {n} other file(s)"
|
||||||
|
# What renders a css family (milestone 302): the consumer count is
|
||||||
|
# the datum that separates a shared recipe from a scoped convention.
|
||||||
|
cons = f.get("consumers")
|
||||||
|
if cons is not None:
|
||||||
|
n_t = cons.get("count", 0)
|
||||||
|
used = f"; used by {n_t} template{'s' if n_t != 1 else ''}"
|
||||||
|
if cons.get("paths"):
|
||||||
|
used += ": " + ", ".join(f"`{x}`" for x in cons["paths"])
|
||||||
|
extra = n_t - len(cons["paths"])
|
||||||
|
if extra > 0:
|
||||||
|
used += f" +{extra} more"
|
||||||
|
files += used
|
||||||
|
# The dismissal reason the family most likely earns: a class name
|
||||||
|
# reused for different purposes is scoped styling; a code name reused
|
||||||
|
# across modules is convention plumbing.
|
||||||
|
dismiss = "scoped-css" if d.get("kind") == "css" else "convention-plumbing"
|
||||||
|
parts.append(
|
||||||
|
f"`{f['label']}` {what}: {files}; derive it now: "
|
||||||
|
"record the canon (create_snippet) and make the copies instances "
|
||||||
|
"(classify_shapes) — or, if these are convention not copies, "
|
||||||
|
f"`classify_shapes(..., status=\"exempt\", reason_code=\"{dismiss}\")` "
|
||||||
|
"dismisses the family — rather than adding another copy"
|
||||||
|
)
|
||||||
|
return f"> Shape ledger at `{path}`: " + "; ".join(parts) + "."
|
||||||
|
|
||||||
|
|
||||||
def _divergence_line(path: str, divergence: list[dict]) -> str:
|
def _divergence_line(path: str, divergence: list[dict]) -> str:
|
||||||
"""Button B where button A is canon — named at the write (#2793)."""
|
"""Button B where button A is canon — named at the write (#2793)."""
|
||||||
parts = [
|
parts = [
|
||||||
@@ -1097,7 +1213,14 @@ async def build_session_context(
|
|||||||
at _MAX_CHARS with an explicit truncation note so the hook can pass it
|
at _MAX_CHARS with an explicit truncation note so the hook can pass it
|
||||||
through verbatim.
|
through verbatim.
|
||||||
"""
|
"""
|
||||||
rules = await rulebooks_svc.list_always_on_rules(user_id)
|
# Inside a project, the always-on set is the project's: an inception
|
||||||
|
# exclusion (milestone 297) takes a rulebook out of this block, and is
|
||||||
|
# named below so the departure is visible rather than silent.
|
||||||
|
rules = await rulebooks_svc.list_always_on_rules(user_id, project_id=project_id)
|
||||||
|
excluded = (
|
||||||
|
await rulebooks_svc.excluded_always_on_rulebooks(user_id, project_id)
|
||||||
|
if project_id else []
|
||||||
|
)
|
||||||
topic_map = await _topic_titles({r.topic_id for r in rules if r.topic_id})
|
topic_map = await _topic_titles({r.topic_id for r in rules if r.topic_id})
|
||||||
|
|
||||||
lines: list[str] = [
|
lines: list[str] = [
|
||||||
@@ -1119,6 +1242,12 @@ async def build_session_context(
|
|||||||
heading = topic_map.get(r.topic_id, "ungrouped") if r.topic_id else "ungrouped"
|
heading = topic_map.get(r.topic_id, "ungrouped") if r.topic_id else "ungrouped"
|
||||||
lines.append(f"### {heading}")
|
lines.append(f"### {heading}")
|
||||||
lines.append(f"- [{r.id}] {r.title}")
|
lines.append(f"- [{r.id}] {r.title}")
|
||||||
|
if excluded:
|
||||||
|
names = ", ".join(f"{e['title']} (#{e['id']})" for e in excluded)
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
f"Excluded for this project by its inception decision (not binding here): {names}.",
|
||||||
|
]
|
||||||
|
|
||||||
project_dict: dict | None = None
|
project_dict: dict | None = None
|
||||||
if project_id:
|
if project_id:
|
||||||
|
|||||||
@@ -17,9 +17,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import case, func, select
|
||||||
|
|
||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
|
from scribe.models.base import iso
|
||||||
from scribe.models.note import Note
|
from scribe.models.note import Note
|
||||||
|
from scribe.models.note_usage import PULLED, SURFACED, NoteUsageEvent
|
||||||
from scribe.models.retrieval_log import RetrievalLog
|
from scribe.models.retrieval_log import RetrievalLog
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -102,11 +109,19 @@ def record_retrieval(
|
|||||||
limit: int | None,
|
limit: int | None,
|
||||||
project_id: int | None,
|
project_id: int | None,
|
||||||
is_task: bool | None,
|
is_task: bool | None,
|
||||||
results: list[tuple[float, Note]],
|
results: list[tuple[float, Any]],
|
||||||
duration_ms: float | None = None,
|
duration_ms: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Fire-and-forget: record one retrieval call.
|
"""Fire-and-forget: record one retrieval call.
|
||||||
|
|
||||||
|
`results` needs only `.id` on each record, which is why it is not typed to
|
||||||
|
Note: rules are retrieved too (milestone 307) and land here rather than in
|
||||||
|
note_usage_events. That table's ids are REMAPPED on a backup restore, so a
|
||||||
|
rule id written into it would come back attached to whatever note happened
|
||||||
|
to take that number — silent corruption of the very evidence this exists to
|
||||||
|
provide. retrieval_logs is not restored at all, so it has no such hazard,
|
||||||
|
and `source` already distinguishes the surfaces.
|
||||||
|
|
||||||
Builds the payload inline (synchronously) then schedules the insert so the
|
Builds the payload inline (synchronously) then schedules the insert so the
|
||||||
caller returns immediately. Never raises — telemetry must not affect search.
|
caller returns immediately. Never raises — telemetry must not affect search.
|
||||||
"""
|
"""
|
||||||
@@ -135,3 +150,205 @@ def record_retrieval(
|
|||||||
return
|
return
|
||||||
_pending.add(task)
|
_pending.add(task)
|
||||||
task.add_done_callback(_pending.discard)
|
task.add_done_callback(_pending.discard)
|
||||||
|
|
||||||
|
|
||||||
|
# --- The read half (#2975) ---------------------------------------------------
|
||||||
|
# Until this existed, `retrieval_logs` was WRITE-ONLY: rows accrued and the only
|
||||||
|
# `select()` over them in the whole tree lived in a test. That made #1038's gate
|
||||||
|
# — "build the reranker once telemetry shows precision is the bottleneck" —
|
||||||
|
# unsatisfiable by construction, and it is why the one real tuning decision on
|
||||||
|
# record (the 0.68 write-path threshold, #2223) was reached by hand-probing the
|
||||||
|
# live instance with eight payloads instead of by reading what was collected.
|
||||||
|
|
||||||
|
def _bucket(rows: list) -> dict:
|
||||||
|
"""A score readout a human can act on, from one aggregate row."""
|
||||||
|
calls, zero, cleared, p10, p50, p90, lo, hi, avg_n, dur = rows
|
||||||
|
return {
|
||||||
|
"calls": int(calls or 0),
|
||||||
|
# A call that returned nothing is not a low-scoring call — it is a
|
||||||
|
# different failure (nothing indexed, filter too narrow), and averaging
|
||||||
|
# it into the score distribution would hide both.
|
||||||
|
"zero_result_calls": int(zero or 0),
|
||||||
|
# How often the best hit actually cleared the threshold in force for
|
||||||
|
# that call. THE precision-adjacent number: a surface that clears its
|
||||||
|
# bar on almost every call is either well-tuned or too loose, and the
|
||||||
|
# score spread below says which.
|
||||||
|
"cleared_threshold": int(cleared or 0),
|
||||||
|
"top_score": {
|
||||||
|
"p10": _round(p10), "p50": _round(p50), "p90": _round(p90),
|
||||||
|
"min": _round(lo), "max": _round(hi),
|
||||||
|
},
|
||||||
|
"avg_result_count": _round(avg_n),
|
||||||
|
"p90_duration_ms": _round(dur, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _round(v, places: int = 4):
|
||||||
|
return None if v is None else round(float(v), places)
|
||||||
|
|
||||||
|
|
||||||
|
async def retrieval_summary(user_id: int | None, *, days: int = 30) -> dict:
|
||||||
|
"""What the retrieval telemetry says, per surface, over a window.
|
||||||
|
|
||||||
|
Two aggregates side by side, each read from the table built for it — NOT a
|
||||||
|
join. `NoteUsageEvent`'s own docstring is explicit that the two are
|
||||||
|
complements ("RetrievalLog tunes the threshold, this tunes the corpus") and
|
||||||
|
that RetrievalLog's JSONB `result_ids` "can't be indexed at" the per-note
|
||||||
|
grain. So the score distribution comes from `retrieval_logs` on its indexed
|
||||||
|
columns, and surfaced-vs-pulled comes from `note_usage_events` at the grain
|
||||||
|
it was built for. Reading each from its own table is both cheaper and more
|
||||||
|
honest than correlating them through JSONB.
|
||||||
|
|
||||||
|
Scoped to one user's own telemetry. There is no sharing model for a
|
||||||
|
retrieval log — it records what THIS user's agent asked for, including the
|
||||||
|
query text — so an owner filter is the whole access rule here rather than a
|
||||||
|
shortcut around `services/access.py` (P#78 governs shared record kinds).
|
||||||
|
|
||||||
|
Never raises: a telemetry readout that can break its caller is worse than
|
||||||
|
no readout. It does distinguish "no rows" from "the read failed", because
|
||||||
|
#2663 is exactly the bug where those two looked identical for weeks.
|
||||||
|
"""
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(days=max(1, int(days)))
|
||||||
|
out: dict = {
|
||||||
|
"window_days": int(days),
|
||||||
|
"since": iso(since),
|
||||||
|
"sources": {},
|
||||||
|
"usage": {},
|
||||||
|
"read_failed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
cleared = case(
|
||||||
|
(
|
||||||
|
(RetrievalLog.threshold.isnot(None))
|
||||||
|
& (RetrievalLog.top_score.isnot(None))
|
||||||
|
& (RetrievalLog.top_score >= RetrievalLog.threshold),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
else_=0,
|
||||||
|
)
|
||||||
|
zero = case((RetrievalLog.result_count == 0, 1), else_=0)
|
||||||
|
|
||||||
|
def pct(p: float):
|
||||||
|
return func.percentile_cont(p).within_group(RetrievalLog.top_score.asc())
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(
|
||||||
|
RetrievalLog.source,
|
||||||
|
func.count().label("calls"),
|
||||||
|
func.sum(zero).label("zero"),
|
||||||
|
func.sum(cleared).label("cleared"),
|
||||||
|
pct(0.1), pct(0.5), pct(0.9),
|
||||||
|
func.min(RetrievalLog.top_score),
|
||||||
|
func.max(RetrievalLog.top_score),
|
||||||
|
func.avg(RetrievalLog.result_count),
|
||||||
|
func.percentile_cont(0.9).within_group(
|
||||||
|
RetrievalLog.duration_ms.asc()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
RetrievalLog.created_at >= since,
|
||||||
|
RetrievalLog.user_id == user_id,
|
||||||
|
)
|
||||||
|
.group_by(RetrievalLog.source)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for row in rows:
|
||||||
|
out["sources"][row[0]] = _bucket(list(row[1:]))
|
||||||
|
|
||||||
|
# The corpus side, at its own grain. `ambient` mirrors
|
||||||
|
# note_usage.usage_for_notes: an ambient surfacing was not a scored
|
||||||
|
# CHOICE, so folding it into pull-through would understate it.
|
||||||
|
# Grouped by RAW source, then classified in Python. The
|
||||||
|
# alternative — CASE expressions in the GROUP BY — is the shape
|
||||||
|
# that produced #2663: a second case() renders its own expanding
|
||||||
|
# bind names, the database sees two different expressions and
|
||||||
|
# rejects the query, and the broad except swallows it. One CASE is
|
||||||
|
# provably fine (usage_for_notes does it); two is where it broke.
|
||||||
|
# `source` has a handful of distinct values, so grouping on it
|
||||||
|
# directly is cheap and cannot fail that way at all.
|
||||||
|
urows = (
|
||||||
|
await session.execute(
|
||||||
|
select(
|
||||||
|
NoteUsageEvent.event,
|
||||||
|
NoteUsageEvent.source,
|
||||||
|
func.count().label("n"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
NoteUsageEvent.created_at >= since,
|
||||||
|
NoteUsageEvent.user_id == user_id,
|
||||||
|
)
|
||||||
|
.group_by(NoteUsageEvent.event, NoteUsageEvent.source)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Distinct-note counts need their OWN queries, and this is not
|
||||||
|
# fussiness: count(distinct note_id) per (event, source) group
|
||||||
|
# cannot be summed across groups — a note surfaced by two sources
|
||||||
|
# is one distinct note and would be counted twice. A wrong number
|
||||||
|
# labelled "distinct" is worse than no number.
|
||||||
|
from scribe.services.note_usage import AMBIENT_SOURCES as _AMB
|
||||||
|
|
||||||
|
distinct_surfaced = (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count(func.distinct(NoteUsageEvent.note_id))).where(
|
||||||
|
NoteUsageEvent.created_at >= since,
|
||||||
|
NoteUsageEvent.user_id == user_id,
|
||||||
|
NoteUsageEvent.event == SURFACED,
|
||||||
|
NoteUsageEvent.source.notin_(_AMB),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
distinct_pulled = (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count(func.distinct(NoteUsageEvent.note_id))).where(
|
||||||
|
NoteUsageEvent.created_at >= since,
|
||||||
|
NoteUsageEvent.user_id == user_id,
|
||||||
|
NoteUsageEvent.event == PULLED,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("retrieval summary read failed", exc_info=True)
|
||||||
|
out["read_failed"] = True
|
||||||
|
return out
|
||||||
|
|
||||||
|
from scribe.services.note_usage import AMBIENT_SOURCES
|
||||||
|
|
||||||
|
usage = {
|
||||||
|
"surfaced": 0, "ambient": 0,
|
||||||
|
"pulled": 0, "pulled_by_agent": 0, "pulled_by_human": 0,
|
||||||
|
"distinct_notes_surfaced": int(distinct_surfaced or 0),
|
||||||
|
"distinct_notes_pulled": int(distinct_pulled or 0),
|
||||||
|
}
|
||||||
|
for event, source, n in urows:
|
||||||
|
n = int(n)
|
||||||
|
if event == SURFACED:
|
||||||
|
if source in AMBIENT_SOURCES:
|
||||||
|
usage["ambient"] += n
|
||||||
|
else:
|
||||||
|
usage["surfaced"] += n
|
||||||
|
elif event == PULLED:
|
||||||
|
usage["pulled"] += n
|
||||||
|
# The mcp_/rest_ split is load-bearing (see NoteUsageEvent's own
|
||||||
|
# comment, which names #1038 — this readout's whole purpose). "Is
|
||||||
|
# this record dead weight?" is answered by ANY pull; "was that
|
||||||
|
# injected line useful to the agent?" only by an AGENT pull. So
|
||||||
|
# pull-through, which exists to answer the second, counts mcp_*
|
||||||
|
# only. Both halves are reported so the first question is still
|
||||||
|
# answerable from the same payload.
|
||||||
|
if source.startswith("mcp_"):
|
||||||
|
usage["pulled_by_agent"] += n
|
||||||
|
else:
|
||||||
|
usage["pulled_by_human"] += n
|
||||||
|
# Ranked surfacings in the denominator, agent pulls in the numerator: the
|
||||||
|
# "surfaced often, opened never" reading is only valid where a scored
|
||||||
|
# surface CHOSE the record and an agent was the one who declined it.
|
||||||
|
usage["pull_through"] = (
|
||||||
|
round(usage["pulled_by_agent"] / usage["surfaced"], 4)
|
||||||
|
if usage["surfaced"] else None
|
||||||
|
)
|
||||||
|
out["usage"] = usage
|
||||||
|
return out
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import delete as sql_delete, insert, or_, select
|
||||||
|
|
||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
|
from scribe.models.system import System
|
||||||
from scribe.models.rulebook import Rulebook
|
from scribe.models.rulebook import Rulebook
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -223,7 +224,7 @@ async def delete_topic(topic_id: int, user_id: int) -> None:
|
|||||||
|
|
||||||
# ── Rule CRUD ──────────────────────────────────────────────────────────
|
# ── Rule CRUD ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
from scribe.models.rulebook import Rule
|
from scribe.models.rulebook import Rule, RuleRelation, rule_systems
|
||||||
|
|
||||||
|
|
||||||
async def _assert_topic_owned(session, topic_id: int, user_id: int) -> None:
|
async def _assert_topic_owned(session, topic_id: int, user_id: int) -> None:
|
||||||
@@ -280,9 +281,161 @@ async def _assert_rulebook_rule_owned(session, rule_id: int, user_id: int) -> No
|
|||||||
raise ValueError(f"rule {rule_id} not found or not a rulebook rule")
|
raise ValueError(f"rule {rule_id} not found or not a rulebook rule")
|
||||||
|
|
||||||
|
|
||||||
|
# The vocabularies migration 0088's CHECK constraints enforce. Named here so
|
||||||
|
# a caller can be corrected before the database refuses it (rule 36 keeps the
|
||||||
|
# two in step; this keeps the error readable).
|
||||||
|
TIERS = ("always_on", "conditional")
|
||||||
|
RELATION_KINDS = ("co_surfaces", "overrides", "elaborates")
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_tier(tier: str) -> str:
|
||||||
|
"""An unrecognised tier falls back to always_on — the SAFE direction.
|
||||||
|
|
||||||
|
Getting this wrong the other way would silently stop a rule binding, which
|
||||||
|
is the one failure this whole milestone exists to prevent. A rule that
|
||||||
|
preloads when it did not need to costs context; a rule that quietly stops
|
||||||
|
preloading costs the behaviour it was written for.
|
||||||
|
"""
|
||||||
|
return tier if tier in TIERS else "always_on"
|
||||||
|
|
||||||
|
|
||||||
|
def rule_brief(rule: Rule, **extra) -> dict:
|
||||||
|
"""The shape a rule takes when it is SURFACED rather than opened.
|
||||||
|
|
||||||
|
One builder for every payload that hands rules to an agent, because there
|
||||||
|
were three copies of this dict and they had already diverged — two carried
|
||||||
|
`topic_id`, one didn't, and none carried the timestamps the model has held
|
||||||
|
all along. That omission is why a rule written before the capability it
|
||||||
|
duplicates was indistinguishable, at read time, from one still doing work
|
||||||
|
(the FabledCurator case, note 3026).
|
||||||
|
|
||||||
|
`updated_at` is a DATE, not a stamp: the question it answers is "how old
|
||||||
|
is this?", and a full ISO string across an always-on set is ~2k characters
|
||||||
|
of payload for a precision nobody reads.
|
||||||
|
|
||||||
|
`why` and `how_to_apply` are deliberately NOT here — they are the depth a
|
||||||
|
caller gets from get_rule, and putting them in every listing is the bloat
|
||||||
|
this milestone is about.
|
||||||
|
"""
|
||||||
|
out = {
|
||||||
|
"id": rule.id,
|
||||||
|
"title": rule.title,
|
||||||
|
"statement": rule.statement,
|
||||||
|
"topic_id": rule.topic_id,
|
||||||
|
"tier": rule.tier,
|
||||||
|
"updated_at": rule.updated_at.date().isoformat() if rule.updated_at else None,
|
||||||
|
}
|
||||||
|
# Attached only when present (#2483: never a null key that reads as a
|
||||||
|
# capability the record doesn't have).
|
||||||
|
if rule.when_to_apply:
|
||||||
|
out["when_to_apply"] = rule.when_to_apply
|
||||||
|
if rule.arose_from_id:
|
||||||
|
out["arose_from_id"] = rule.arose_from_id
|
||||||
|
out.update({k: v for k, v in extra.items() if v is not None})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_rule_embedding(rule: Rule) -> None:
|
||||||
|
"""Re-index a rule after a write. Fire-and-forget, like the note twin.
|
||||||
|
|
||||||
|
Lazy import so this module doesn't pull in the embedder; every exception
|
||||||
|
swallowed because a rule that SAVED must not fail on its index refresh —
|
||||||
|
a stale vector costs a missed search hit, a raised exception costs the
|
||||||
|
write. No running loop (unit tests, scripts) is ordinary, not an error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from scribe.services.embeddings import upsert_rule_embedding
|
||||||
|
|
||||||
|
asyncio.create_task(
|
||||||
|
upsert_rule_embedding(
|
||||||
|
rule.id, rule.title, rule.statement, rule.when_to_apply,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
pass # no running loop — a sync caller, not a failure
|
||||||
|
except Exception: # noqa: BLE001 - never let indexing break a write
|
||||||
|
logger.exception("embedding refresh failed for rule %s", rule.id)
|
||||||
|
|
||||||
|
|
||||||
|
async def co_surfaced_partners(
|
||||||
|
user_id: int, rule_ids: list[int], exclude_ids: set[int] | None = None,
|
||||||
|
) -> list[Rule]:
|
||||||
|
"""Rules that must arrive WITH the given ones, because they fail together.
|
||||||
|
|
||||||
|
This is the whole reason `co_surfaces` exists. Rule 144 was split off rule
|
||||||
|
46 and folded back into it the same day, on the correct observation that
|
||||||
|
"either rule could surface without the other and miss exposing a project to
|
||||||
|
what the entire shape is intended to be." Merging was the only fix
|
||||||
|
available; this is the fix that should have been available.
|
||||||
|
|
||||||
|
Two limits, both deliberate:
|
||||||
|
|
||||||
|
- Only rules the caller OWNS. An edge is not a back door into someone
|
||||||
|
else's rulebook.
|
||||||
|
- `exclude_ids` is honoured, and callers pass the project's SUPPRESSIONS.
|
||||||
|
A project that explicitly muted a rule should not have it dragged back in
|
||||||
|
by an edge — the suppression is a decision, and the edge does not
|
||||||
|
outrank it.
|
||||||
|
"""
|
||||||
|
if not rule_ids:
|
||||||
|
return []
|
||||||
|
known = set(rule_ids) | (exclude_ids or set())
|
||||||
|
async with async_session() as session:
|
||||||
|
edges = (await session.execute(
|
||||||
|
select(RuleRelation).where(
|
||||||
|
RuleRelation.kind == "co_surfaces",
|
||||||
|
or_(
|
||||||
|
RuleRelation.from_rule_id.in_(rule_ids),
|
||||||
|
RuleRelation.to_rule_id.in_(rule_ids),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)).scalars().all()
|
||||||
|
partners = {
|
||||||
|
(edge.to_rule_id if edge.from_rule_id in known else edge.from_rule_id)
|
||||||
|
for edge in edges
|
||||||
|
} - known
|
||||||
|
if not partners:
|
||||||
|
return []
|
||||||
|
# Ownership re-checked per partner rather than assumed from the edge.
|
||||||
|
out = []
|
||||||
|
for partner_id in sorted(partners):
|
||||||
|
rule = await _fetch_owned_rule(session, partner_id, user_id)
|
||||||
|
if rule is not None:
|
||||||
|
out.append(rule)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def rule_detail(user_id: int, rule: Rule, system_ids: list[int] | None = None) -> dict:
|
||||||
|
"""The full record, with its areas and edges attached.
|
||||||
|
|
||||||
|
ONE seam for both doors and every write path, so create, update and get
|
||||||
|
cannot disagree about what a rule looks like coming back — the same
|
||||||
|
reasoning as attach_relations for notes (#2859), and the same reasoning
|
||||||
|
rule_brief exists for one level down.
|
||||||
|
|
||||||
|
`system_ids=None` means "leave the tags alone"; a list (including [])
|
||||||
|
REPLACES them.
|
||||||
|
"""
|
||||||
|
if system_ids is not None:
|
||||||
|
await set_rule_systems(rule.id, user_id, system_ids)
|
||||||
|
data = rule.to_dict()
|
||||||
|
systems = (await list_rule_systems([rule.id])).get(rule.id, [])
|
||||||
|
relations = (await list_rule_relations([rule.id])).get(rule.id, [])
|
||||||
|
# Attached only when present (#2483): an empty key reads as a capability
|
||||||
|
# the record has and isn't using, which is a different claim.
|
||||||
|
if systems:
|
||||||
|
data["systems"] = systems
|
||||||
|
if relations:
|
||||||
|
data["relations"] = relations
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
async def create_rule(
|
async def create_rule(
|
||||||
topic_id: int, user_id: int, title: str, statement: str,
|
topic_id: int, user_id: int, title: str, statement: str,
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||||
|
when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0,
|
||||||
) -> Rule:
|
) -> Rule:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
await _assert_topic_owned(session, topic_id, user_id)
|
await _assert_topic_owned(session, topic_id, user_id)
|
||||||
@@ -290,19 +443,24 @@ async def create_rule(
|
|||||||
topic_id=topic_id,
|
topic_id=topic_id,
|
||||||
title=title,
|
title=title,
|
||||||
statement=statement,
|
statement=statement,
|
||||||
|
when_to_apply=when_to_apply or None,
|
||||||
|
tier=_valid_tier(tier),
|
||||||
why=why or None,
|
why=why or None,
|
||||||
how_to_apply=how_to_apply or None,
|
how_to_apply=how_to_apply or None,
|
||||||
|
arose_from_id=arose_from_id or None,
|
||||||
order_index=order_index,
|
order_index=order_index,
|
||||||
)
|
)
|
||||||
session.add(rule)
|
session.add(rule)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(rule)
|
await session.refresh(rule)
|
||||||
|
_refresh_rule_embedding(rule)
|
||||||
return rule
|
return rule
|
||||||
|
|
||||||
|
|
||||||
async def create_project_rule(
|
async def create_project_rule(
|
||||||
project_id: int, user_id: int, title: str, statement: str,
|
project_id: int, user_id: int, title: str, statement: str,
|
||||||
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
why: str = "", how_to_apply: str = "", order_index: int = 0,
|
||||||
|
when_to_apply: str = "", tier: str = "always_on", arose_from_id: int = 0,
|
||||||
) -> Rule:
|
) -> Rule:
|
||||||
"""Create a rule scoped to a single project (no rulebook ceremony).
|
"""Create a rule scoped to a single project (no rulebook ceremony).
|
||||||
|
|
||||||
@@ -316,13 +474,17 @@ async def create_project_rule(
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
title=title,
|
title=title,
|
||||||
statement=statement,
|
statement=statement,
|
||||||
|
when_to_apply=when_to_apply or None,
|
||||||
|
tier=_valid_tier(tier),
|
||||||
why=why or None,
|
why=why or None,
|
||||||
how_to_apply=how_to_apply or None,
|
how_to_apply=how_to_apply or None,
|
||||||
|
arose_from_id=arose_from_id or None,
|
||||||
order_index=order_index,
|
order_index=order_index,
|
||||||
)
|
)
|
||||||
session.add(rule)
|
session.add(rule)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(rule)
|
await session.refresh(rule)
|
||||||
|
_refresh_rule_embedding(rule)
|
||||||
return rule
|
return rule
|
||||||
|
|
||||||
|
|
||||||
@@ -394,15 +556,57 @@ async def list_rules(
|
|||||||
return rulebook_rules + list(proj_result.scalars().all())
|
return rulebook_rules + list(proj_result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def list_always_on_rules(user_id: int, limit: int = 100) -> list[Rule]:
|
def _excluded_rulebook_ids_q(project_id: int):
|
||||||
|
"""Subquery: the always-on rulebooks this project opted out of at
|
||||||
|
inception (milestone 297) — used by every rule-resolution path so an
|
||||||
|
exclusion is total, not just cosmetic."""
|
||||||
|
from scribe.models.rulebook import project_rulebook_exclusions
|
||||||
|
|
||||||
|
return select(project_rulebook_exclusions.c.rulebook_id).where(
|
||||||
|
project_rulebook_exclusions.c.project_id == project_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def excluded_always_on_rulebooks(user_id: int, project_id: int) -> list[dict]:
|
||||||
|
"""[{id, title}] of the always-on rulebooks excluded for ``project_id``
|
||||||
|
(owner-scoped). Empty for an undecided or inherit-all project."""
|
||||||
|
from scribe.models.rulebook import project_rulebook_exclusions
|
||||||
|
|
||||||
|
if not project_id:
|
||||||
|
return []
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(Rulebook.id, Rulebook.title)
|
||||||
|
.join(project_rulebook_exclusions,
|
||||||
|
project_rulebook_exclusions.c.rulebook_id == Rulebook.id)
|
||||||
|
.where(
|
||||||
|
project_rulebook_exclusions.c.project_id == project_id,
|
||||||
|
Rulebook.owner_user_id == user_id,
|
||||||
|
Rulebook.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Rulebook.title)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return [{"id": rid, "title": title} for rid, title in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_always_on_rules(
|
||||||
|
user_id: int, limit: int = 100, project_id: int = 0,
|
||||||
|
) -> list[Rule]:
|
||||||
"""Return all rules from rulebooks flagged always_on for the user.
|
"""Return all rules from rulebooks flagged always_on for the user.
|
||||||
|
|
||||||
Called by the MCP tool of the same name at session start to load the
|
Called by the MCP tool of the same name at session start to load the
|
||||||
standing rules that apply regardless of which project (if any) is in
|
standing rules that apply regardless of which project (if any) is in
|
||||||
scope. Ordering matches list_rules so results are stable across calls.
|
scope. Ordering matches list_rules so results are stable across calls.
|
||||||
|
|
||||||
|
``project_id`` (milestone 297): inside a project that excluded specific
|
||||||
|
always-on rulebooks at inception, those rulebooks' rules are NOT
|
||||||
|
returned — the project decided not to inherit them. 0 = the user-wide
|
||||||
|
set, which is what a session sees before a project is in scope.
|
||||||
"""
|
"""
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
q = (
|
||||||
select(Rule)
|
select(Rule)
|
||||||
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
||||||
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||||
@@ -412,11 +616,25 @@ async def list_always_on_rules(user_id: int, limit: int = 100) -> list[Rule]:
|
|||||||
Rule.deleted_at.is_(None),
|
Rule.deleted_at.is_(None),
|
||||||
RulebookTopic.deleted_at.is_(None),
|
RulebookTopic.deleted_at.is_(None),
|
||||||
Rulebook.deleted_at.is_(None),
|
Rulebook.deleted_at.is_(None),
|
||||||
|
# TIER (milestone 307). This is the SESSION-START call, made
|
||||||
|
# before any project is in scope — there is no area vocabulary
|
||||||
|
# to match a conditional rule against yet, so only the
|
||||||
|
# unconditional tier belongs here. A conditional rule reaches a
|
||||||
|
# session through enter_project (by area) or search (by
|
||||||
|
# meaning), not by being resident.
|
||||||
|
#
|
||||||
|
# Behaviour is unchanged until rules are actually re-tiered:
|
||||||
|
# `tier` defaults to always_on, so every existing rule still
|
||||||
|
# arrives exactly as it did.
|
||||||
|
Rule.tier == "always_on",
|
||||||
)
|
)
|
||||||
.order_by(
|
)
|
||||||
|
if project_id:
|
||||||
|
q = q.where(Rulebook.id.notin_(_excluded_rulebook_ids_q(project_id)))
|
||||||
|
result = await session.execute(
|
||||||
|
q.order_by(
|
||||||
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
|
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
|
||||||
)
|
).limit(limit)
|
||||||
.limit(limit)
|
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@@ -468,15 +686,175 @@ async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]:
|
|||||||
rule = await _fetch_owned_rule(session, rule_id, user_id)
|
rule = await _fetch_owned_rule(session, rule_id, user_id)
|
||||||
if rule is None:
|
if rule is None:
|
||||||
return None
|
return None
|
||||||
allowed = {"title", "statement", "why", "how_to_apply", "order_index"}
|
allowed = {
|
||||||
|
"title", "statement", "why", "how_to_apply", "order_index",
|
||||||
|
"when_to_apply", "tier", "arose_from_id",
|
||||||
|
}
|
||||||
for key, value in fields.items():
|
for key, value in fields.items():
|
||||||
if key in allowed and value is not None:
|
if key in allowed and value is not None:
|
||||||
setattr(rule, key, value)
|
setattr(rule, key, _valid_tier(value) if key == "tier" else value)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(rule)
|
await session.refresh(rule)
|
||||||
|
_refresh_rule_embedding(rule)
|
||||||
return rule
|
return rule
|
||||||
|
|
||||||
|
|
||||||
|
# ── Canon tags + typed edges (milestone 307) ───────────────────────────
|
||||||
|
|
||||||
|
async def set_rule_systems(
|
||||||
|
rule_id: int, user_id: int, canonical_ids: list[int],
|
||||||
|
) -> list[int] | None:
|
||||||
|
"""Replace which global AREAS a rule is about. None if not owned.
|
||||||
|
|
||||||
|
Set-semantics like set_record_systems: the list given IS the state after,
|
||||||
|
so an empty list clears the tags. Points at the canonical catalog, never a
|
||||||
|
project's System — a family rule tagged to one project's row would bind
|
||||||
|
itself to that project's vocabulary.
|
||||||
|
"""
|
||||||
|
from scribe.models.canonical_system import CanonicalSystem
|
||||||
|
from scribe.models.rulebook import rule_systems as rule_systems_t
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
rule = await _fetch_owned_rule(session, rule_id, user_id)
|
||||||
|
if rule is None:
|
||||||
|
return None
|
||||||
|
wanted = set(canonical_ids or [])
|
||||||
|
if wanted:
|
||||||
|
live = set((await session.execute(
|
||||||
|
select(CanonicalSystem.id).where(
|
||||||
|
CanonicalSystem.id.in_(wanted),
|
||||||
|
CanonicalSystem.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)).scalars().all())
|
||||||
|
# Silently dropping an unknown id would leave the caller believing
|
||||||
|
# a tag exists; keep only the live ones and report what stuck.
|
||||||
|
wanted &= live
|
||||||
|
await session.execute(
|
||||||
|
sql_delete(rule_systems_t).where(rule_systems_t.c.rule_id == rule_id)
|
||||||
|
)
|
||||||
|
for canonical_id in sorted(wanted):
|
||||||
|
await session.execute(
|
||||||
|
insert(rule_systems_t).values(rule_id=rule_id, canonical_id=canonical_id)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return sorted(wanted)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_rule_systems(rule_ids: list[int]) -> dict[int, list[dict]]:
|
||||||
|
"""The canon tags for a batch of rules, keyed by rule id.
|
||||||
|
|
||||||
|
Batched on purpose: the surfacing paths ask about a whole payload of rules
|
||||||
|
at once, and one query per rule would turn every session start into an
|
||||||
|
N+1.
|
||||||
|
"""
|
||||||
|
from scribe.models.canonical_system import CanonicalSystem
|
||||||
|
from scribe.models.rulebook import rule_systems as rule_systems_t
|
||||||
|
|
||||||
|
if not rule_ids:
|
||||||
|
return {}
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(rule_systems_t.c.rule_id, CanonicalSystem.id, CanonicalSystem.name)
|
||||||
|
.join(CanonicalSystem, CanonicalSystem.id == rule_systems_t.c.canonical_id)
|
||||||
|
.where(
|
||||||
|
rule_systems_t.c.rule_id.in_(rule_ids),
|
||||||
|
CanonicalSystem.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(CanonicalSystem.order_index)
|
||||||
|
)).all()
|
||||||
|
out: dict[int, list[dict]] = {}
|
||||||
|
for rule_id, canonical_id, name in rows:
|
||||||
|
out.setdefault(rule_id, []).append({"id": canonical_id, "name": name})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def add_rule_relation(
|
||||||
|
user_id: int, from_rule_id: int, to_rule_id: int, kind: str, note: str = "",
|
||||||
|
) -> RuleRelation | None:
|
||||||
|
"""Draw a typed edge between two rules. None if either isn't owned.
|
||||||
|
|
||||||
|
Both ends are ownership-checked: an edge is only meaningful if the drawer
|
||||||
|
can see both rules, and a one-sided edge would surface a rule the caller
|
||||||
|
has no business reading.
|
||||||
|
|
||||||
|
Idempotent — re-drawing an existing edge returns it rather than raising, so
|
||||||
|
a true-up pass can be re-run without cleaning up first.
|
||||||
|
"""
|
||||||
|
if kind not in RELATION_KINDS:
|
||||||
|
raise ValueError(f"kind must be one of {RELATION_KINDS}, got {kind!r}")
|
||||||
|
if from_rule_id == to_rule_id:
|
||||||
|
raise ValueError("a rule cannot relate to itself")
|
||||||
|
async with async_session() as session:
|
||||||
|
for rid in (from_rule_id, to_rule_id):
|
||||||
|
if await _fetch_owned_rule(session, rid, user_id) is None:
|
||||||
|
return None
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(RuleRelation).where(
|
||||||
|
RuleRelation.from_rule_id == from_rule_id,
|
||||||
|
RuleRelation.to_rule_id == to_rule_id,
|
||||||
|
RuleRelation.kind == kind,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
relation = RuleRelation(
|
||||||
|
from_rule_id=from_rule_id, to_rule_id=to_rule_id,
|
||||||
|
kind=kind, note=note or None,
|
||||||
|
)
|
||||||
|
session.add(relation)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(relation)
|
||||||
|
return relation
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_rule_relation(user_id: int, relation_id: int) -> bool:
|
||||||
|
async with async_session() as session:
|
||||||
|
relation = await session.get(RuleRelation, relation_id)
|
||||||
|
if relation is None:
|
||||||
|
return False
|
||||||
|
if await _fetch_owned_rule(session, relation.from_rule_id, user_id) is None:
|
||||||
|
return False
|
||||||
|
await session.delete(relation)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def list_rule_relations(rule_ids: list[int]) -> dict[int, list[dict]]:
|
||||||
|
"""Edges touching a batch of rules, keyed by rule id.
|
||||||
|
|
||||||
|
`co_surfaces` is reported from BOTH ends off a single stored row — it means
|
||||||
|
"these fail together", which is not a claim with a direction. The other two
|
||||||
|
are directional and are reported as stored, with `direction` naming which
|
||||||
|
end this rule is: an override read from the wrong end would invert what it
|
||||||
|
says.
|
||||||
|
"""
|
||||||
|
if not rule_ids:
|
||||||
|
return {}
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(RuleRelation).where(
|
||||||
|
(RuleRelation.from_rule_id.in_(rule_ids))
|
||||||
|
| (RuleRelation.to_rule_id.in_(rule_ids))
|
||||||
|
)
|
||||||
|
)).scalars().all()
|
||||||
|
out: dict[int, list[dict]] = {}
|
||||||
|
wanted = set(rule_ids)
|
||||||
|
for relation in rows:
|
||||||
|
if relation.from_rule_id in wanted:
|
||||||
|
out.setdefault(relation.from_rule_id, []).append({
|
||||||
|
"id": relation.id, "kind": relation.kind,
|
||||||
|
"rule_id": relation.to_rule_id,
|
||||||
|
"direction": "outgoing", "note": relation.note or "",
|
||||||
|
})
|
||||||
|
if relation.to_rule_id in wanted:
|
||||||
|
out.setdefault(relation.to_rule_id, []).append({
|
||||||
|
"id": relation.id, "kind": relation.kind,
|
||||||
|
"rule_id": relation.from_rule_id,
|
||||||
|
"direction": "incoming", "note": relation.note or "",
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def delete_rule(rule_id: int, user_id: int) -> None:
|
async def delete_rule(rule_id: int, user_id: int) -> None:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
rule = await _fetch_owned_rule(session, rule_id, user_id)
|
rule = await _fetch_owned_rule(session, rule_id, user_id)
|
||||||
@@ -488,7 +866,7 @@ async def delete_rule(rule_id: int, user_id: int) -> None:
|
|||||||
|
|
||||||
# ── Subscriptions + get_applicable_rules ───────────────────────────────
|
# ── Subscriptions + get_applicable_rules ───────────────────────────────
|
||||||
|
|
||||||
from sqlalchemy import insert, delete as sql_delete
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
|
||||||
async def subscribe_project(
|
async def subscribe_project(
|
||||||
@@ -568,6 +946,51 @@ async def unsuppress_rule_for_project(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def exclude_always_on_rulebook_for_project(
|
||||||
|
project_id: int, rulebook_id: int, user_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Opt one project out of a whole ALWAYS-ON rulebook (milestone 297).
|
||||||
|
Owner-only on both sides; the rulebook must be always_on — a subscribed
|
||||||
|
rulebook is left by unsubscribing, not excluding. Idempotent."""
|
||||||
|
from scribe.models.rulebook import project_rulebook_exclusions
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
await _assert_project_owned(session, project_id, user_id)
|
||||||
|
await _assert_rulebook_owned(session, rulebook_id, user_id)
|
||||||
|
rb = await session.get(Rulebook, rulebook_id)
|
||||||
|
if rb is None or not rb.always_on:
|
||||||
|
raise ValueError(
|
||||||
|
f"rulebook {rulebook_id} is not always-on — it binds only by "
|
||||||
|
"subscription; unsubscribe_project_from_rulebook instead"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await session.execute(
|
||||||
|
insert(project_rulebook_exclusions).values(
|
||||||
|
project_id=project_id, rulebook_id=rulebook_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await session.rollback() # already excluded — idempotent
|
||||||
|
|
||||||
|
|
||||||
|
async def include_always_on_rulebook_for_project(
|
||||||
|
project_id: int, rulebook_id: int, user_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Undo exclude_always_on_rulebook_for_project. Idempotent."""
|
||||||
|
from scribe.models.rulebook import project_rulebook_exclusions
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
await _assert_project_owned(session, project_id, user_id)
|
||||||
|
await session.execute(
|
||||||
|
sql_delete(project_rulebook_exclusions).where(
|
||||||
|
project_rulebook_exclusions.c.project_id == project_id,
|
||||||
|
project_rulebook_exclusions.c.rulebook_id == rulebook_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def suppress_topic_for_project(
|
async def suppress_topic_for_project(
|
||||||
project_id: int, topic_id: int, user_id: int,
|
project_id: int, topic_id: int, user_id: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -711,10 +1134,13 @@ async def get_applicable_rules(
|
|||||||
# Applicable rules (limit + 1 so we can detect truncation). Filter
|
# Applicable rules (limit + 1 so we can detect truncation). Filter
|
||||||
# in SQL so truncation reflects the post-suppression count, not the
|
# in SQL so truncation reflects the post-suppression count, not the
|
||||||
# raw subscription count.
|
# raw subscription count.
|
||||||
|
# Selects the ENTITY, not a column list: rule_brief is the one place
|
||||||
|
# that decides which fields a surfaced rule carries, and a column list
|
||||||
|
# here would be a second such decision to keep in step. The row count
|
||||||
|
# is bounded by `limit`, so this is a listing, not a scan.
|
||||||
rules_q = (
|
rules_q = (
|
||||||
select(
|
select(
|
||||||
Rule.id, Rule.title, Rule.statement,
|
Rule,
|
||||||
RulebookTopic.id.label("topic_id"),
|
|
||||||
RulebookTopic.title.label("topic_title"),
|
RulebookTopic.title.label("topic_title"),
|
||||||
Rulebook.id.label("rulebook_id"),
|
Rulebook.id.label("rulebook_id"),
|
||||||
Rulebook.title.label("rulebook_title"),
|
Rulebook.title.label("rulebook_title"),
|
||||||
@@ -731,6 +1157,9 @@ async def get_applicable_rules(
|
|||||||
Rule.deleted_at.is_(None),
|
Rule.deleted_at.is_(None),
|
||||||
RulebookTopic.deleted_at.is_(None),
|
RulebookTopic.deleted_at.is_(None),
|
||||||
Rulebook.deleted_at.is_(None),
|
Rulebook.deleted_at.is_(None),
|
||||||
|
# An inception exclusion is total (milestone 297): a rulebook the
|
||||||
|
# project opted out of contributes nothing, subscribed or not.
|
||||||
|
Rulebook.id.notin_(_excluded_rulebook_ids_q(project_id)),
|
||||||
)
|
)
|
||||||
.order_by(
|
.order_by(
|
||||||
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
|
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
|
||||||
@@ -741,21 +1170,41 @@ async def get_applicable_rules(
|
|||||||
rules_q = rules_q.where(Rule.id.notin_(suppressed_rule_ids))
|
rules_q = rules_q.where(Rule.id.notin_(suppressed_rule_ids))
|
||||||
if suppressed_topic_ids:
|
if suppressed_topic_ids:
|
||||||
rules_q = rules_q.where(Rule.topic_id.notin_(suppressed_topic_ids))
|
rules_q = rules_q.where(Rule.topic_id.notin_(suppressed_topic_ids))
|
||||||
|
# TIER (milestone 307). always_on rules are resident, as every rule was
|
||||||
|
# before tiers existed. A conditional rule is REACHABLE, and reaches
|
||||||
|
# this project only when it is tagged to an area this project actually
|
||||||
|
# works in — a deterministic tag match, never a similarity score, so
|
||||||
|
# bindingness never depends on a ranking (D7).
|
||||||
|
#
|
||||||
|
# Applied in SQL rather than by filtering afterwards, so `limit` counts
|
||||||
|
# the rules that will actually be surfaced instead of counting rules
|
||||||
|
# that are about to be dropped.
|
||||||
|
project_area_ids = (await session.execute(
|
||||||
|
select(System.canonical_id).where(
|
||||||
|
System.project_id == project_id,
|
||||||
|
System.canonical_id.is_not(None),
|
||||||
|
System.deleted_at.is_(None),
|
||||||
|
System.status == "active",
|
||||||
|
).distinct()
|
||||||
|
)).scalars().all()
|
||||||
|
reachable = select(rule_systems.c.rule_id).where(
|
||||||
|
rule_systems.c.canonical_id.in_(project_area_ids)
|
||||||
|
) if project_area_ids else None
|
||||||
|
tier_clause = (Rule.tier == "always_on")
|
||||||
|
if reachable is not None:
|
||||||
|
tier_clause = or_(tier_clause, Rule.id.in_(reachable))
|
||||||
|
rules_q = rules_q.where(tier_clause)
|
||||||
rule_rows = (await session.execute(rules_q)).all()
|
rule_rows = (await session.execute(rules_q)).all()
|
||||||
truncated = len(rule_rows) > limit
|
truncated = len(rule_rows) > limit
|
||||||
rules = [
|
rules = [
|
||||||
{
|
rule_brief(rule, topic_title=tt, rulebook_id=rbi, rulebook_title=rbt)
|
||||||
"id": rid, "title": rtitle, "statement": stmt,
|
for rule, tt, rbi, rbt in rule_rows[:limit]
|
||||||
"topic_id": ti, "topic_title": tt,
|
|
||||||
"rulebook_id": rbi, "rulebook_title": rbt,
|
|
||||||
}
|
|
||||||
for rid, rtitle, stmt, ti, tt, rbi, rbt in rule_rows[:limit]
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Project-scoped rules — verifies ownership via Project.user_id.
|
# Project-scoped rules — verifies ownership via Project.user_id.
|
||||||
from scribe.models.project import Project
|
from scribe.models.project import Project
|
||||||
proj_rules_q = (
|
proj_rules_q = (
|
||||||
select(Rule.id, Rule.title, Rule.statement)
|
select(Rule)
|
||||||
.join(Project, Rule.project_id == Project.id)
|
.join(Project, Rule.project_id == Project.id)
|
||||||
.where(
|
.where(
|
||||||
Project.user_id == user_id,
|
Project.user_id == user_id,
|
||||||
@@ -765,11 +1214,39 @@ async def get_applicable_rules(
|
|||||||
)
|
)
|
||||||
.order_by(Rule.order_index, Rule.title)
|
.order_by(Rule.order_index, Rule.title)
|
||||||
)
|
)
|
||||||
|
if reachable is not None:
|
||||||
|
proj_rules_q = proj_rules_q.where(
|
||||||
|
or_(Rule.tier == "always_on", Rule.id.in_(reachable))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
proj_rules_q = proj_rules_q.where(Rule.tier == "always_on")
|
||||||
proj_rule_rows = (await session.execute(proj_rules_q)).all()
|
proj_rule_rows = (await session.execute(proj_rules_q)).all()
|
||||||
project_rules = [
|
project_rules = [rule_brief(rule) for (rule,) in proj_rule_rows]
|
||||||
{"id": rid, "title": rtitle, "statement": stmt}
|
|
||||||
for rid, rtitle, stmt in proj_rule_rows
|
# Edges travel with the rules they belong to (milestone 307).
|
||||||
]
|
#
|
||||||
|
# A co_surfaces partner that was not otherwise selected is ADDED, because a
|
||||||
|
# rule that arrives without the half it fails with is the failure the edge
|
||||||
|
# was created to prevent. Suppressions are passed as exclusions so an
|
||||||
|
# explicit mute still wins over an edge.
|
||||||
|
surfaced_ids = [r["id"] for r in rules] + [r["id"] for r in project_rules]
|
||||||
|
partners = await co_surfaced_partners(
|
||||||
|
user_id, surfaced_ids, exclude_ids=set(suppressed_rule_ids),
|
||||||
|
)
|
||||||
|
for partner in partners:
|
||||||
|
rules.append(rule_brief(partner, via="co_surfaces"))
|
||||||
|
surfaced_ids.append(partner.id)
|
||||||
|
|
||||||
|
# Relations on every surfaced rule, so a reader can see that an override
|
||||||
|
# exists rather than discovering the contradiction by acting on the wrong
|
||||||
|
# one. Areas too — they are why a conditional rule is here at all.
|
||||||
|
edges = await list_rule_relations(surfaced_ids)
|
||||||
|
areas = await list_rule_systems(surfaced_ids)
|
||||||
|
for brief in (*rules, *project_rules):
|
||||||
|
if edges.get(brief["id"]):
|
||||||
|
brief["relations"] = edges[brief["id"]]
|
||||||
|
if areas.get(brief["id"]):
|
||||||
|
brief["systems"] = areas[brief["id"]]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"rules": rules,
|
"rules": rules,
|
||||||
@@ -778,6 +1255,7 @@ async def get_applicable_rules(
|
|||||||
"suppressed_topics": suppressed_topics,
|
"suppressed_topics": suppressed_topics,
|
||||||
"truncated": truncated,
|
"truncated": truncated,
|
||||||
"subscribed_rulebooks": subscribed_rulebooks,
|
"subscribed_rulebooks": subscribed_rulebooks,
|
||||||
|
"excluded_always_on": await excluded_always_on_rulebooks(user_id, project_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -786,9 +1264,12 @@ def rules_payload(applicable: dict) -> dict:
|
|||||||
|
|
||||||
Every surface that hands rules to an agent (enter_project, get_project,
|
Every surface that hands rules to an agent (enter_project, get_project,
|
||||||
get_milestone, get_task for legacy plans, start_planning) carries the
|
get_milestone, get_task for legacy plans, start_planning) carries the
|
||||||
same six keys under the same names — so a reader learns them once. One
|
same seven keys under the same names — so a reader learns them once. One
|
||||||
place renames `rules` → `applicable_rules` and `truncated` →
|
place renames `rules` → `applicable_rules` and `truncated` →
|
||||||
`applicable_rules_truncated`; the tools merge this into their payloads.
|
`applicable_rules_truncated`; the tools merge this into their payloads.
|
||||||
|
`excluded_always_on` (milestone 297) names the always-on rulebooks this
|
||||||
|
project decided NOT to inherit, so the departure is visible wherever the
|
||||||
|
rules are.
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"applicable_rules": applicable["rules"],
|
"applicable_rules": applicable["rules"],
|
||||||
@@ -797,4 +1278,5 @@ def rules_payload(applicable: dict) -> dict:
|
|||||||
"project_rules": applicable.get("project_rules", []),
|
"project_rules": applicable.get("project_rules", []),
|
||||||
"suppressed_rules": applicable.get("suppressed_rules", []),
|
"suppressed_rules": applicable.get("suppressed_rules", []),
|
||||||
"suppressed_topics": applicable.get("suppressed_topics", []),
|
"suppressed_topics": applicable.get("suppressed_topics", []),
|
||||||
|
"excluded_always_on": applicable.get("excluded_always_on", []),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ from typing import Iterable, NamedTuple
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
from scribe.models.code_shape import REASON_CODES, CodeShape, CodeShapeEvent, CodeShapeUse
|
from scribe.models.code_shape import (
|
||||||
|
REASON_CODES, CodeShape, CodeShapeConsumer, CodeShapeEvent, CodeShapeUse,
|
||||||
|
)
|
||||||
from scribe.models.base import iso
|
from scribe.models.base import iso
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -262,6 +264,137 @@ async def uses_of(shape_ids) -> dict[int, list[CodeShapeUse]]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- the CSS consumer map (milestone 302) ------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_consumers(
|
||||||
|
css_rows: Iterable[tuple[int, str, str]],
|
||||||
|
references: dict[str, dict[str, int]],
|
||||||
|
) -> dict[tuple[int, str], int]:
|
||||||
|
"""{(shape_id, consumer_path): count} — which CSS rows each file's markup
|
||||||
|
consumes. ``css_rows`` are (id, path, symbol) of the repo's live css rows;
|
||||||
|
``references`` is scan_archive's path → class token → count.
|
||||||
|
|
||||||
|
Resolution (note 2917): a class named in file F resolves to F's OWN row
|
||||||
|
of that name when F defines it (a scoped rule is consumed by its own
|
||||||
|
template); otherwise to every other file's row of that name — a shared
|
||||||
|
sheet, or, when several files define it, all of them: the map says
|
||||||
|
"ambiguous" by fanning out rather than guessing one.
|
||||||
|
|
||||||
|
A token ending in ``PREFIX_MARK`` is a PREFIX reference (#2970) — the
|
||||||
|
static head of a name the template concatenates, `status-*` from
|
||||||
|
`` `status-${s}` ``. It stands for every row whose symbol starts with
|
||||||
|
that head, each resolved by the same own-file-else-fan-out rule. The
|
||||||
|
template cannot tell us WHICH of them it built, so the map credits all
|
||||||
|
of them rather than calling live rules unused."""
|
||||||
|
# Lazy, like the extract_definitions import below: coverage reaches into
|
||||||
|
# this module during a refresh, so neither may import the other at load.
|
||||||
|
from scribe.services.coverage import PREFIX_MARK
|
||||||
|
|
||||||
|
by_symbol: dict[str, list[tuple[int, str]]] = {}
|
||||||
|
for sid, path, symbol in css_rows:
|
||||||
|
by_symbol.setdefault(symbol, []).append((sid, path))
|
||||||
|
out: dict[tuple[int, str], int] = {}
|
||||||
|
|
||||||
|
def credit(rows: list[tuple[int, str]], consumer: str, count: int) -> None:
|
||||||
|
own = [sid for sid, path in rows if path == consumer]
|
||||||
|
for sid in own or [sid for sid, _path in rows]:
|
||||||
|
out[(sid, consumer)] = out.get((sid, consumer), 0) + int(count)
|
||||||
|
|
||||||
|
for consumer, tokens in references.items():
|
||||||
|
for token, count in tokens.items():
|
||||||
|
if token.endswith(PREFIX_MARK):
|
||||||
|
head = token[: -len(PREFIX_MARK)]
|
||||||
|
for symbol, rows in by_symbol.items():
|
||||||
|
if symbol.startswith(head):
|
||||||
|
credit(rows, consumer, count)
|
||||||
|
continue
|
||||||
|
rows = by_symbol.get(token)
|
||||||
|
if rows:
|
||||||
|
credit(rows, consumer, count)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_repo_consumers(
|
||||||
|
project_id: int, repo_key: str, references: dict[str, dict[str, int]]
|
||||||
|
) -> int:
|
||||||
|
"""Rebuild one repo's consumer edges from its archive's class references:
|
||||||
|
insert the new, refresh changed counts, delete what the tree no longer
|
||||||
|
says (a template rewritten, a class renamed, a file gone). Edges hang on
|
||||||
|
live rows only; a vanished row's edges go with this pass. Returns how
|
||||||
|
many edges stand afterwards."""
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(CodeShape.id, CodeShape.path, CodeShape.symbol, CodeShape.vanished_at).where(
|
||||||
|
CodeShape.project_id == project_id,
|
||||||
|
CodeShape.repo_key == repo_key,
|
||||||
|
CodeShape.kind == "css",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
live = [(r[0], r[1], r[2]) for r in rows if r[3] is None]
|
||||||
|
all_ids = [r[0] for r in rows]
|
||||||
|
wanted = resolve_consumers(live, references)
|
||||||
|
existing = (
|
||||||
|
await session.execute(
|
||||||
|
select(CodeShapeConsumer).where(CodeShapeConsumer.shape_id.in_(all_ids))
|
||||||
|
)
|
||||||
|
).scalars().all() if all_ids else []
|
||||||
|
have = {(e.shape_id, e.path): e for e in existing}
|
||||||
|
for key, edge in have.items():
|
||||||
|
if key not in wanted:
|
||||||
|
await session.delete(edge)
|
||||||
|
elif edge.count != wanted[key]:
|
||||||
|
edge.count = wanted[key]
|
||||||
|
for (sid, path), count in wanted.items():
|
||||||
|
if (sid, path) not in have:
|
||||||
|
session.add(CodeShapeConsumer(shape_id=sid, path=path, count=count, basis="template"))
|
||||||
|
await session.commit()
|
||||||
|
return len(wanted)
|
||||||
|
|
||||||
|
|
||||||
|
async def consumers_of(shape_ids) -> dict[int, list[CodeShapeConsumer]]:
|
||||||
|
"""{shape_id: [edges]} for a set of rows — the read side of the map,
|
||||||
|
ordered by path so a readout is stable."""
|
||||||
|
ids = [int(x) for x in shape_ids if x]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
async with async_session() as session:
|
||||||
|
edges = (
|
||||||
|
await session.execute(
|
||||||
|
select(CodeShapeConsumer).where(CodeShapeConsumer.shape_id.in_(ids))
|
||||||
|
.order_by(CodeShapeConsumer.shape_id, CodeShapeConsumer.path)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
out: dict[int, list[CodeShapeConsumer]] = {}
|
||||||
|
for e in edges:
|
||||||
|
out.setdefault(e.shape_id, []).append(e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# How many consumer files a readout names before "+N more".
|
||||||
|
_CONSUMERS_SHOWN = 4
|
||||||
|
|
||||||
|
|
||||||
|
def consumer_summary(paths: Iterable[str]) -> dict:
|
||||||
|
"""{"count", "paths"} — distinct consumer files, sorted, the first few
|
||||||
|
named. The one shape every surface uses for "used by N template(s)"."""
|
||||||
|
files = sorted(set(paths))
|
||||||
|
return {"count": len(files), "paths": files[:_CONSUMERS_SHOWN]}
|
||||||
|
|
||||||
|
|
||||||
|
async def used_by_map(rows: Iterable[CodeShape]) -> dict[int, dict]:
|
||||||
|
"""{shape_id: consumer_summary} for every css row given — a row with no
|
||||||
|
consumer gets {"count": 0, "paths": []}: "no template names it" is a
|
||||||
|
finding, not an absence."""
|
||||||
|
css = [r for r in rows if r.kind == "css"]
|
||||||
|
if not css:
|
||||||
|
return {}
|
||||||
|
edges = await consumers_of([r.id for r in css])
|
||||||
|
return {r.id: consumer_summary(e.path for e in edges.get(r.id, [])) for r in css}
|
||||||
|
|
||||||
|
|
||||||
async def mark_canonicals(
|
async def mark_canonicals(
|
||||||
project_id: int, recorded: list[tuple[int, str, str]]
|
project_id: int, recorded: list[tuple[int, str, str]]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -574,7 +707,8 @@ async def list_project_shapes(
|
|||||||
suggestion), "derive" (a repeats-with-no-canon group), or one basis
|
suggestion), "derive" (a repeats-with-no-canon group), or one basis
|
||||||
name (symbol/reference/text/signature/semantic). ``flag`` narrows to
|
name (symbol/reference/text/signature/semantic). ``flag`` narrows to
|
||||||
the readout's asks (#2793): "divergence" (new where a canon dominates,
|
the readout's asks (#2793): "divergence" (new where a canon dominates,
|
||||||
`diverges_from` names it) or "recheck" (a judged shape whose body moved).
|
`diverges_from` names it), "recheck" (a judged shape whose body moved),
|
||||||
|
or "unused-css" (milestone 302: a css rule no template names).
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
|
|
||||||
@@ -609,6 +743,13 @@ async def list_project_shapes(
|
|||||||
conds.append(CodeShape.diverges_from.isnot(None))
|
conds.append(CodeShape.diverges_from.isnot(None))
|
||||||
elif flag == "recheck":
|
elif flag == "recheck":
|
||||||
conds.append(CodeShape.recheck_at.isnot(None))
|
conds.append(CodeShape.recheck_at.isnot(None))
|
||||||
|
elif flag == "unused-css":
|
||||||
|
# The consumer map's negative space (milestone 302): a live css rule
|
||||||
|
# no file's markup names. A candidate for deletion, surfaced — never
|
||||||
|
# deleted — because the map reads templates only (a class built at
|
||||||
|
# runtime, or used from a script, is invisible to it).
|
||||||
|
conds.append(CodeShape.kind == "css")
|
||||||
|
conds.append(~CodeShape.id.in_(select(CodeShapeConsumer.shape_id)))
|
||||||
if uses:
|
if uses:
|
||||||
# Consumers of a canon (#2870): rows with a uses edge to it, whatever
|
# Consumers of a canon (#2870): rows with a uses edge to it, whatever
|
||||||
# shape they themselves are.
|
# shape they themselves are.
|
||||||
@@ -923,6 +1064,13 @@ async def stamp_write_path_instances(
|
|||||||
# recur by convention, not by duplication).
|
# recur by convention, not by duplication).
|
||||||
_DERIVE_MIN_DUP = 2
|
_DERIVE_MIN_DUP = 2
|
||||||
_DERIVE_MIN_NAME = 3
|
_DERIVE_MIN_NAME = 3
|
||||||
|
# CSS is never grouped by body (note 2917): classes for different purposes
|
||||||
|
# share declarations because the style system makes them alike — `.text-muted`
|
||||||
|
# and `.pin-badge-auto` carrying the same `color: var(--fs-text-tertiary)` are
|
||||||
|
# two meanings, not two copies. A CSS family is a NAME defined in more than
|
||||||
|
# one file: that is a recipe living in several places, and two is already
|
||||||
|
# the signal (a class name is deliberate in a way `setup`/`load` are not).
|
||||||
|
_DERIVE_MIN_NAME_CSS = 2
|
||||||
# Semantic checks per repo per refresh — an embedding each (local fastembed),
|
# Semantic checks per repo per refresh — an embedding each (local fastembed),
|
||||||
# bounded so a 4,000-row ledger is worked through over refreshes, not in one.
|
# bounded so a 4,000-row ledger is worked through over refreshes, not in one.
|
||||||
_SEMANTIC_CAP = 150
|
_SEMANTIC_CAP = 150
|
||||||
@@ -1298,15 +1446,16 @@ def derive_groups(
|
|||||||
rows: Iterable[tuple[str, str, str, str]]
|
rows: Iterable[tuple[str, str, str, str]]
|
||||||
) -> dict[tuple[str, str, str], str]:
|
) -> dict[tuple[str, str, str], str]:
|
||||||
"""The derive-first grouping over (path, kind, symbol, body_sha) rows
|
"""The derive-first grouping over (path, kind, symbol, body_sha) rows
|
||||||
that matched no canon: {(path, kind, symbol): group_key}. Identical
|
that matched no canon: {(path, kind, symbol): group_key}. For code
|
||||||
bodies in ≥2 places group as `dup:<sha>`; the same name defined in ≥3
|
(kind `sym`) identical bodies in ≥2 places group as `dup:<sha>` and the
|
||||||
files groups as `name:<kind>:<symbol>`; a row joins at most one group,
|
same name defined in ≥3 files groups as `name:sym:<symbol>`, the copy
|
||||||
the copy before the name."""
|
before the name. CSS groups by name only — the same class defined in
|
||||||
|
≥2 files is `name:css:<symbol>`; its body never groups it (note 2917)."""
|
||||||
by_sha: dict[str, list[tuple[str, str, str]]] = {}
|
by_sha: dict[str, list[tuple[str, str, str]]] = {}
|
||||||
by_name: dict[tuple[str, str], list[tuple[str, str, str]]] = {}
|
by_name: dict[tuple[str, str], list[tuple[str, str, str]]] = {}
|
||||||
for path, kind, symbol, sha in rows:
|
for path, kind, symbol, sha in rows:
|
||||||
key = (path, kind, symbol)
|
key = (path, kind, symbol)
|
||||||
if sha:
|
if sha and kind != "css":
|
||||||
by_sha.setdefault(sha, []).append(key)
|
by_sha.setdefault(sha, []).append(key)
|
||||||
by_name.setdefault((kind, _norm_symbol(symbol)), []).append(key)
|
by_name.setdefault((kind, _norm_symbol(symbol)), []).append(key)
|
||||||
out: dict[tuple[str, str, str], str] = {}
|
out: dict[tuple[str, str, str], str] = {}
|
||||||
@@ -1315,7 +1464,8 @@ def derive_groups(
|
|||||||
for key in keys:
|
for key in keys:
|
||||||
out.setdefault(key, f"dup:{sha}")
|
out.setdefault(key, f"dup:{sha}")
|
||||||
for (kind, symbol), keys in by_name.items():
|
for (kind, symbol), keys in by_name.items():
|
||||||
if len({k[0] for k in keys}) >= _DERIVE_MIN_NAME:
|
floor = _DERIVE_MIN_NAME_CSS if kind == "css" else _DERIVE_MIN_NAME
|
||||||
|
if len({k[0] for k in keys}) >= floor:
|
||||||
for key in keys:
|
for key in keys:
|
||||||
out.setdefault(key, f"name:{kind}:{symbol}")
|
out.setdefault(key, f"name:{kind}:{symbol}")
|
||||||
return out
|
return out
|
||||||
@@ -1360,13 +1510,21 @@ async def apply_derive_groups(project_id: int) -> int:
|
|||||||
return grouped
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
def proposal_summary(rows: Iterable[CodeShape], *, top: int = 8) -> dict:
|
def proposal_summary(
|
||||||
|
rows: Iterable[CodeShape], *, top: int = 8,
|
||||||
|
consumer_paths: dict[int, list[str]] | None = None,
|
||||||
|
) -> dict:
|
||||||
"""The readout's view of the proposer's standing: how many canon
|
"""The readout's view of the proposer's standing: how many canon
|
||||||
proposals await confirmation, and the largest derive-first groups."""
|
proposals await confirmation, and the largest derive-first groups.
|
||||||
|
``consumer_paths`` (shape_id → files whose markup names it, milestone
|
||||||
|
302) puts `consumers` on each group — the family's distinct consumer
|
||||||
|
files across its members, the datum that separates a shared recipe
|
||||||
|
from a scoped convention."""
|
||||||
proposed = 0
|
proposed = 0
|
||||||
by_canon: dict[int, int] = {}
|
by_canon: dict[int, int] = {}
|
||||||
groups: dict[str, dict] = {}
|
groups: dict[str, dict] = {}
|
||||||
files: dict[str, set[str]] = {}
|
files: dict[str, set[str]] = {}
|
||||||
|
consumers: dict[str, set[str]] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if row.status not in _MECHANICAL_TODO:
|
if row.status not in _MECHANICAL_TODO:
|
||||||
continue
|
continue
|
||||||
@@ -1387,8 +1545,14 @@ def proposal_summary(rows: Iterable[CodeShape], *, top: int = 8) -> dict:
|
|||||||
files.setdefault(row.proposal_group, set()).add(row.path)
|
files.setdefault(row.proposal_group, set()).add(row.path)
|
||||||
if len(g["paths"]) < 3:
|
if len(g["paths"]) < 3:
|
||||||
g["paths"].append(row.path)
|
g["paths"].append(row.path)
|
||||||
|
if consumer_paths is not None and row.kind == "css":
|
||||||
|
consumers.setdefault(row.proposal_group, set()).update(
|
||||||
|
consumer_paths.get(row.id) or ()
|
||||||
|
)
|
||||||
for key, g in groups.items():
|
for key, g in groups.items():
|
||||||
g["files"] = len(files[key])
|
g["files"] = len(files[key])
|
||||||
|
if key in consumers:
|
||||||
|
g["consumers"] = consumer_summary(consumers[key])
|
||||||
# Body-identical groups first (#2872): the things an audit actually
|
# Body-identical groups first (#2872): the things an audit actually
|
||||||
# consolidated were identical bodies under different names/files; a
|
# consolidated were identical bodies under different names/files; a
|
||||||
# name repeated across modules is usually convention. Within a tier,
|
# name repeated across modules is usually convention. Within a tier,
|
||||||
@@ -1404,6 +1568,34 @@ def proposal_summary(rows: Iterable[CodeShape], *, top: int = 8) -> dict:
|
|||||||
return {"proposed": proposed, "derive_groups": ranked[:top], "top_canon": top_canon}
|
return {"proposed": proposed, "derive_groups": ranked[:top], "top_canon": top_canon}
|
||||||
|
|
||||||
|
|
||||||
|
def derive_new_summary(
|
||||||
|
rows: Iterable[CodeShape], *, since: datetime | None, top: int = 3
|
||||||
|
) -> dict:
|
||||||
|
"""The arrival-moment drift signal (#2899): derive-grouped rows FIRST
|
||||||
|
SEEN after ``since`` — the previous refresh's stamp, the same one
|
||||||
|
flag_divergence uses. "Since the last refresh, N more copies joined a
|
||||||
|
duplicate family" is the sentence that makes the derive queue a thing
|
||||||
|
you notice on entering, not a thing an audit finds. ``since`` None (a
|
||||||
|
first seed) means nothing is new. Judged rows never count."""
|
||||||
|
if since is None:
|
||||||
|
return {"count": 0, "examples": []}
|
||||||
|
fresh = [
|
||||||
|
r for r in rows
|
||||||
|
if r.proposal_basis == "derive" and r.proposal_group
|
||||||
|
and r.status in _MECHANICAL_TODO and r.vanished_at is None
|
||||||
|
and r.created_at is not None and r.created_at > since
|
||||||
|
]
|
||||||
|
fresh.sort(key=lambda r: r.created_at, reverse=True)
|
||||||
|
return {
|
||||||
|
"count": len(fresh),
|
||||||
|
"examples": [
|
||||||
|
{"label": ("." if r.kind == "css" else "") + r.symbol,
|
||||||
|
"path": r.path, "group": r.proposal_group}
|
||||||
|
for r in fresh[:top]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def confirm_proposals(
|
async def confirm_proposals(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
project_id: int,
|
project_id: int,
|
||||||
@@ -1566,6 +1758,82 @@ async def write_time_divergence(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# How many other files a family line names before "…" — enough to go look,
|
||||||
|
# not a wall.
|
||||||
|
_DERIVE_FILES_SHOWN = 4
|
||||||
|
|
||||||
|
|
||||||
|
async def write_time_derive(
|
||||||
|
project_id: int, path: str, shapes: list[tuple[str, str]]
|
||||||
|
) -> list[dict]:
|
||||||
|
"""The in-band DERIVE check (#2900): for each (kind, name) the hook
|
||||||
|
named at ``path``, what the ledger already knows about that name
|
||||||
|
elsewhere in the project —
|
||||||
|
|
||||||
|
family the name sits in a derive-first group (code: identical body
|
||||||
|
in N files or the same name in ≥3; CSS: the same class in
|
||||||
|
≥2 files, note 2917): "this is a known family with no canon
|
||||||
|
— derive it now, don't add a copy";
|
||||||
|
canon a `canonical` row of that name at another path: "this is
|
||||||
|
canon #N at <path> — reuse, don't redefine".
|
||||||
|
|
||||||
|
Only for shapes not yet judged at ``path`` (a judged shape is not
|
||||||
|
re-litigated at every edit), never for the canon's own file. Returns
|
||||||
|
[{symbol, kind, key, family?|canon?}] — `key` is the dedup token the
|
||||||
|
hook keeps per session (the group id, or canon:<snippet_id>)."""
|
||||||
|
wanted = {(k, _norm_symbol(n)): n for k, n in shapes if n}
|
||||||
|
if not wanted:
|
||||||
|
return []
|
||||||
|
async with async_session() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(CodeShape).where(
|
||||||
|
CodeShape.project_id == project_id,
|
||||||
|
CodeShape.vanished_at.is_(None),
|
||||||
|
CodeShape.symbol.in_({norm for (_k, norm) in wanted}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
out: list[dict] = []
|
||||||
|
for (kind, norm), name in wanted.items():
|
||||||
|
same = [r for r in rows if r.kind == kind and _norm_symbol(r.symbol) == norm]
|
||||||
|
here = next((r for r in same if r.path == path), None)
|
||||||
|
if here is not None and here.status not in _MECHANICAL_TODO:
|
||||||
|
continue # judged here (or this IS the canon): nothing to say
|
||||||
|
others = [r for r in same if r.path != path]
|
||||||
|
label = ("." if kind == "css" else "") + name
|
||||||
|
canon = next((r for r in others if r.status == "canonical" and r.snippet_id), None)
|
||||||
|
if canon is not None:
|
||||||
|
out.append({"symbol": name, "kind": kind, "key": f"canon:{canon.snippet_id}",
|
||||||
|
"canon": {"snippet_id": canon.snippet_id, "path": canon.path,
|
||||||
|
"label": label}})
|
||||||
|
continue
|
||||||
|
grouped = [r for r in others if r.proposal_group and r.status in _MECHANICAL_TODO]
|
||||||
|
if here is not None and here.proposal_group:
|
||||||
|
grouped = [r for r in grouped if r.proposal_group == here.proposal_group] or grouped
|
||||||
|
if not grouped:
|
||||||
|
continue
|
||||||
|
group = grouped[0].proposal_group
|
||||||
|
members = [r for r in grouped if r.proposal_group == group]
|
||||||
|
files = sorted({r.path for r in members})
|
||||||
|
family = {
|
||||||
|
"group": group, "label": label,
|
||||||
|
"identical": not group.startswith("name:"),
|
||||||
|
"files": files[:_DERIVE_FILES_SHOWN], "file_count": len(files),
|
||||||
|
"size": len(members) + (1 if here is not None else 0),
|
||||||
|
}
|
||||||
|
if kind == "css":
|
||||||
|
# What renders the family (milestone 302): the members' consumer
|
||||||
|
# files, the row at `path` included when it already exists.
|
||||||
|
ids = [r.id for r in members] + ([here.id] if here is not None else [])
|
||||||
|
edges = await consumers_of(ids)
|
||||||
|
family["consumers"] = consumer_summary(
|
||||||
|
e.path for es in edges.values() for e in es
|
||||||
|
)
|
||||||
|
out.append({"symbol": name, "kind": kind, "key": group, "family": family})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def flag_divergence(project_id: int, *, since: datetime | None) -> int:
|
async def flag_divergence(project_id: int, *, since: datetime | None) -> int:
|
||||||
"""Flag shapes created after ``since`` (the previous refresh) that sit
|
"""Flag shapes created after ``since`` (the previous refresh) that sit
|
||||||
where a canon dominates and were not proposed as that canon. With no
|
where a canon dominates and were not proposed as that canon. With no
|
||||||
|
|||||||
@@ -987,6 +987,46 @@ async def _refresh_provenance(note, commit_sha: str) -> None:
|
|||||||
await notes_svc.update_note(note.user_id, note.id, data=data)
|
await notes_svc.update_note(note.user_id, note.id, data=data)
|
||||||
|
|
||||||
|
|
||||||
|
def _verdict_still_vouches(note, fields: dict, fetched_commit_sha: str) -> bool:
|
||||||
|
"""Does a standing `ok` verdict still speak for this body, at this commit?
|
||||||
|
|
||||||
|
Containment (cached code ∈ fetched file) is the fast path, and it is right
|
||||||
|
for a record kept verbatim. It is WRONG for a deliberately annotated one
|
||||||
|
(#2782): a record whose job is to say why the shape is what it is carries
|
||||||
|
commentary the source does not, so containment fails forever and the record
|
||||||
|
reads `diverged` on every pull. That turns the one honest drift signal into
|
||||||
|
a permanent false positive — and annotation is a sanctioned record style,
|
||||||
|
so this is two deliberate designs colliding, not a malformed record.
|
||||||
|
|
||||||
|
The escape hatch is the verdict itself. `verify_snippet` is precisely where
|
||||||
|
a human or agent already judged this body a faithful rendering of that
|
||||||
|
source, and `verification.commit_sha` records the repo commit they judged
|
||||||
|
it at — a field whose own docstring (#2688) anticipated this use: "makes
|
||||||
|
'the REPO moved on since the check' computable, once the forge integration
|
||||||
|
can compare it against the current head." This is that comparison.
|
||||||
|
|
||||||
|
All four conditions, and none is optional:
|
||||||
|
- the verdict says `ok`;
|
||||||
|
- it has not EXPIRED — `verification_view` recomputes `code_sha` against
|
||||||
|
the record's current body, so editing the record retires the verdict;
|
||||||
|
- it was not INVALIDATED by a push touching the location (#2691);
|
||||||
|
- the file we just fetched is at the very commit the verdict was stamped
|
||||||
|
at. Any later commit means nobody has judged what is there now.
|
||||||
|
|
||||||
|
The last one is what keeps this honest: it vouches for a body against ONE
|
||||||
|
known commit, never against whatever the source has become since. The
|
||||||
|
moment the file moves, containment resumes as the authority and the record
|
||||||
|
reads `diverged` until someone re-verifies — which is the correct outcome,
|
||||||
|
because at that point nobody has looked.
|
||||||
|
"""
|
||||||
|
if not fetched_commit_sha:
|
||||||
|
return False
|
||||||
|
view = verification_view(note, fields)
|
||||||
|
if view.get("status") != VERIFY_OK or view.get("needs_attention"):
|
||||||
|
return False
|
||||||
|
return view.get("commit_sha") == fetched_commit_sha
|
||||||
|
|
||||||
|
|
||||||
async def attach_live_body(note, data: dict) -> None:
|
async def attach_live_body(note, data: dict) -> None:
|
||||||
"""Decorate a PULL response with forge-checked freshness (#2690).
|
"""Decorate a PULL response with forge-checked freshness (#2690).
|
||||||
|
|
||||||
@@ -1115,6 +1155,14 @@ async def attach_live_body(note, data: dict) -> None:
|
|||||||
_refresh_provenance(note, fetched.commit_sha),
|
_refresh_provenance(note, fetched.commit_sha),
|
||||||
site="pull provenance-refresh",
|
site="pull provenance-refresh",
|
||||||
)
|
)
|
||||||
|
elif _verdict_still_vouches(note, fields, fetched.commit_sha or ""):
|
||||||
|
# Containment failed, but an unexpired `ok` verdict stamped at exactly
|
||||||
|
# this commit already judged this body a faithful rendering of it —
|
||||||
|
# the annotated-record case (#2782). Trust the judgment over the
|
||||||
|
# substring test; `data["verification"]` travels in the same payload,
|
||||||
|
# so a reader can see the basis rather than take "current" on faith.
|
||||||
|
data["body_source"] = "forge"
|
||||||
|
data["body_freshness"] = "current"
|
||||||
else:
|
else:
|
||||||
data["body_source"] = "cache"
|
data["body_source"] = "cache"
|
||||||
data["body_freshness"] = "diverged"
|
data["body_freshness"] = "diverged"
|
||||||
|
|||||||
@@ -14,10 +14,106 @@ from scribe.models import async_session
|
|||||||
from scribe.models.note import Note
|
from scribe.models.note import Note
|
||||||
from scribe.models.system import RecordSystem, System
|
from scribe.models.system import RecordSystem, System
|
||||||
from scribe.services import access
|
from scribe.services import access
|
||||||
|
from scribe.services import canonical_systems as canonical_systems_svc
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def local_name_key(name: str) -> str:
|
||||||
|
"""The within-project uniqueness key: case and spacing, nothing else.
|
||||||
|
|
||||||
|
Deliberately weaker than `canonical_slug`. This one answers "is this the
|
||||||
|
same System I already have here", where the operator's own spelling is the
|
||||||
|
thing being compared; the canonical slug answers "is this the same AREA as
|
||||||
|
some other project's System", where spelling is exactly what must be
|
||||||
|
ignored.
|
||||||
|
"""
|
||||||
|
return " ".join(name.split()).lower()
|
||||||
|
|
||||||
|
|
||||||
|
async def assess_system_name(user_id: int, project_id: int, name: str) -> dict:
|
||||||
|
"""What BOTH doors must know before minting a System name (milestone 307).
|
||||||
|
|
||||||
|
Lived in the MCP tool alone until now, which is how the web UI shipped
|
||||||
|
without a gate the agent surface enforced (#2482). One service function, so
|
||||||
|
the two doors cannot answer the same question differently (rule 33).
|
||||||
|
|
||||||
|
Returns `{"duplicate": …|None, "canonical": …|None}`:
|
||||||
|
|
||||||
|
- `duplicate` — this project already has a System by that name. A hard stop
|
||||||
|
for the caller: a second one splits the area's records across two piles.
|
||||||
|
- `canonical` — the global catalog covers this area, with a `basis`.
|
||||||
|
`exact` is mechanical and safe to apply on the spot; `overlap` is a
|
||||||
|
judgment call and must be OFFERED, never applied. Neither ever blocks:
|
||||||
|
an unmatched name is a project-specific area, which is legitimate.
|
||||||
|
|
||||||
|
Fails open on both arms — a naming aid must never break a create.
|
||||||
|
"""
|
||||||
|
out: dict = {"duplicate": None, "canonical": None}
|
||||||
|
key = local_name_key(name)
|
||||||
|
if not key:
|
||||||
|
return out
|
||||||
|
try:
|
||||||
|
for existing in await list_systems(user_id, project_id, include_archived=True):
|
||||||
|
if local_name_key(existing.name) == key:
|
||||||
|
out["duplicate"] = {"id": existing.id, "name": existing.name}
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
logger.debug("system name assessment: local scan failed", exc_info=True)
|
||||||
|
return out
|
||||||
|
try:
|
||||||
|
exact = await canonical_systems_svc.find_by_name(name)
|
||||||
|
if exact is not None:
|
||||||
|
out["canonical"] = {
|
||||||
|
"id": exact.id, "name": exact.name, "basis": "exact",
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
# No exact hit: fall back to the same overlap scoring the review
|
||||||
|
# surface uses, so a create-time offer and a later proposal never
|
||||||
|
# disagree about which area a name resembles.
|
||||||
|
near = await canonical_systems_svc.best_overlap(name)
|
||||||
|
if near is not None:
|
||||||
|
out["canonical"] = near
|
||||||
|
except Exception:
|
||||||
|
logger.debug("system name assessment: catalog lookup failed", exc_info=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def standard_systems() -> list[tuple[str, str]]:
|
||||||
|
"""The standard cross-project vocabulary (#2798) as (name, charter) pairs.
|
||||||
|
|
||||||
|
Reads the GLOBAL canonical catalog (milestone 307). This was a tuple
|
||||||
|
constant in this module until the catalog became a table: a constant
|
||||||
|
cannot be a foreign key, so nothing outside a project could reference an
|
||||||
|
area, and the list only ever applied on the inception-seed path — which is
|
||||||
|
how three spellings of "CI & Release" reached one instance anyway.
|
||||||
|
"""
|
||||||
|
return [(entry.name, entry.description or "") for entry in
|
||||||
|
await canonical_systems_svc.list_canonical_systems()]
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_standard_systems(user_id: int, project_id: int) -> list[System]:
|
||||||
|
"""Mint the standard starter set for a project that has NO Systems yet
|
||||||
|
(milestone 297). Idempotent: a project with any System — the vocabulary
|
||||||
|
already started, standard or not — gets nothing; the duplicate gate and
|
||||||
|
the project's own judgment take it from there. [] without write access.
|
||||||
|
|
||||||
|
Seeded Systems are mapped to their catalog entry as they are created, so a
|
||||||
|
project born this way needs no reconciliation pass later."""
|
||||||
|
if await list_systems(user_id, project_id, include_archived=True):
|
||||||
|
return []
|
||||||
|
out: list[System] = []
|
||||||
|
for index, entry in enumerate(await canonical_systems_svc.list_canonical_systems()):
|
||||||
|
system = await create_system(
|
||||||
|
user_id, project_id, entry.name, description=entry.description,
|
||||||
|
order_index=index, canonical_id=entry.id,
|
||||||
|
)
|
||||||
|
if system is None:
|
||||||
|
break
|
||||||
|
out.append(system)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def create_system(
|
async def create_system(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
project_id: int,
|
project_id: int,
|
||||||
@@ -25,8 +121,14 @@ async def create_system(
|
|||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
color: str | None = None,
|
color: str | None = None,
|
||||||
order_index: int = 0,
|
order_index: int = 0,
|
||||||
|
canonical_id: int | None = None,
|
||||||
) -> System | None:
|
) -> System | None:
|
||||||
"""Create a System. None if the user can't write the project."""
|
"""Create a System. None if the user can't write the project.
|
||||||
|
|
||||||
|
`canonical_id` maps the new System onto the global catalog; leaving it None
|
||||||
|
is fine — an unmapped System is fully usable, and the mapping can be
|
||||||
|
proposed later (services/canonical_systems.propose_mappings).
|
||||||
|
"""
|
||||||
if not await access.can_write_project(user_id, project_id):
|
if not await access.can_write_project(user_id, project_id):
|
||||||
return None
|
return None
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
@@ -37,6 +139,7 @@ async def create_system(
|
|||||||
description=description,
|
description=description,
|
||||||
color=color,
|
color=color,
|
||||||
order_index=order_index,
|
order_index=order_index,
|
||||||
|
canonical_id=canonical_id,
|
||||||
)
|
)
|
||||||
session.add(system)
|
session.add(system)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -75,6 +178,10 @@ async def list_systems(
|
|||||||
|
|
||||||
async def update_system(user_id: int, system_id: int, **fields: object) -> System | None:
|
async def update_system(user_id: int, system_id: int, **fields: object) -> System | None:
|
||||||
"""Update a System if the user can write its project."""
|
"""Update a System if the user can write its project."""
|
||||||
|
# canonical_id is deliberately NOT settable here: canonical_systems.
|
||||||
|
# set_system_canonical is its single writer, because it also validates the
|
||||||
|
# catalog entry is live. Two entry points onto one column is the drift this
|
||||||
|
# table exists to end.
|
||||||
allowed = {"name", "description", "color", "status", "order_index"}
|
allowed = {"name", "description", "color", "status", "order_index"}
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
system = await session.get(System, system_id)
|
system = await session.get(System, system_id)
|
||||||
|
|||||||
@@ -82,3 +82,24 @@ def _no_supersession():
|
|||||||
with patch("scribe.services.plugin_context.superseded_ids",
|
with patch("scribe.services.plugin_context.superseded_ids",
|
||||||
AsyncMock(return_value=set())):
|
AsyncMock(return_value=set())):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_rule_arm():
|
||||||
|
"""Stub the write-path hint's standing-RULES arm (milestone 307).
|
||||||
|
|
||||||
|
Autouse, and deliberately so. The arm calls semantic_search_rules, which
|
||||||
|
loads the embedding model — so every unrelated plugin-context test that
|
||||||
|
already stubs the NOTES search would otherwise pull a real model into a
|
||||||
|
unit test through the one arm it forgot to stub. The forty-odd existing
|
||||||
|
call sites should not each have to learn about a new arm.
|
||||||
|
|
||||||
|
The arm's own behaviour is covered where it belongs: the document shape in
|
||||||
|
tests/test_services_rule_embeddings.py, the surfacing rules against real
|
||||||
|
Postgres in tests/test_integration_rule_surfacing.py, and the hook's dedup
|
||||||
|
channel in tests/test_write_path_trigger.py. A test that wants the arm
|
||||||
|
live can re-patch it.
|
||||||
|
"""
|
||||||
|
with patch("scribe.services.plugin_context.semantic_search_rules",
|
||||||
|
AsyncMock(return_value=[])):
|
||||||
|
yield
|
||||||
|
|||||||
+45
-2
@@ -6,6 +6,7 @@ them; a module imports what it needs with ``from tests.helpers import ...``.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
@@ -131,7 +132,9 @@ def fake_milestone(**attrs) -> MagicMock:
|
|||||||
|
|
||||||
|
|
||||||
def fake_system(**attrs) -> MagicMock:
|
def fake_system(**attrs) -> MagicMock:
|
||||||
return _with_defaults({"id": 1, "name": "Reader", "project_id": 5}, attrs)
|
return _with_defaults(
|
||||||
|
{"id": 1, "name": "Reader", "project_id": 5, "canonical_id": None}, attrs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def fake_rulebook(**attrs) -> MagicMock:
|
def fake_rulebook(**attrs) -> MagicMock:
|
||||||
@@ -150,8 +153,12 @@ def fake_topic(**attrs) -> MagicMock:
|
|||||||
|
|
||||||
def fake_rule(**attrs) -> MagicMock:
|
def fake_rule(**attrs) -> MagicMock:
|
||||||
return _with_defaults({
|
return _with_defaults({
|
||||||
"id": 1, "topic_id": 10, "title": "dev is home",
|
"id": 1, "topic_id": 10, "project_id": None, "title": "dev is home",
|
||||||
"statement": "Work directly on dev", "why": "", "how_to_apply": "",
|
"statement": "Work directly on dev", "why": "", "how_to_apply": "",
|
||||||
|
# Named for the note-2109 reason the whole helper exists: unnamed,
|
||||||
|
# `when_to_apply` and `arose_from_id` would be truthy MagicMocks and
|
||||||
|
# rule_brief would attach both keys on every stand-in.
|
||||||
|
"when_to_apply": None, "tier": "always_on", "arose_from_id": None,
|
||||||
"order_index": 0, "created_at": _now(), "updated_at": _now(),
|
"order_index": 0, "created_at": _now(), "updated_at": _now(),
|
||||||
}, attrs)
|
}, attrs)
|
||||||
|
|
||||||
@@ -182,3 +189,39 @@ def design_token_stub(name, value_by_mode, group_name=None, purpose=None,
|
|||||||
name=name, value_by_mode=value_by_mode, group_name=group_name,
|
name=name, value_by_mode=value_by_mode, group_name=group_name,
|
||||||
purpose=purpose, order_index=order_index, supersedes=supersedes or [],
|
purpose=purpose, order_index=order_index, supersedes=supersedes or [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def http_sink(reply: bytes = b'{"context":"","note_ids":[]}'):
|
||||||
|
"""A throwaway local HTTP listener for hook end-to-end tests: yields
|
||||||
|
``(port, seen)`` where ``seen`` collects every GET's parsed query string
|
||||||
|
(one dict per request, in order). Lets the shell be tested end to end —
|
||||||
|
the extraction, the encoding, the URL — without a Scribe instance.
|
||||||
|
|
||||||
|
Three test modules each carried their own ``_Sink`` handler before #2904
|
||||||
|
consolidated them here; pass ``reply`` for the body the hook should see.
|
||||||
|
"""
|
||||||
|
import http.server
|
||||||
|
import threading
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
seen: list[dict] = []
|
||||||
|
|
||||||
|
class _Sink(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
seen.append(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query))
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(reply)
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
server = http.server.HTTPServer(("127.0.0.1", 0), _Sink)
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
try:
|
||||||
|
yield server.server_port, seen
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""The PostToolUse after-write hook (#2901): code written through Bash — sed,
|
||||||
|
heredocs, scripts — gets the same prior-art / ledger checks as a Write/Edit.
|
||||||
|
|
||||||
|
Runs the real shell against a temp git repo and a throwaway HTTP sink, like
|
||||||
|
the pre-write hook's end-to-end tests. Skips where the hook's tools are
|
||||||
|
missing; asserts on content where they are present."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.helpers import http_sink
|
||||||
|
|
||||||
|
PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
|
||||||
|
HOOK = PLUGIN / "hooks" / "scribe_after_write.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def _env(tmp_path, url="http://127.0.0.1:9"):
|
||||||
|
for tool in ("git", "jq", "curl", "bash"):
|
||||||
|
if shutil.which(tool) is None:
|
||||||
|
pytest.skip(f"hook runtime tool {tool!r} not installed")
|
||||||
|
return {"PATH": os.environ["PATH"], "SCRIBE_URL": url, "SCRIBE_TOKEN": "t",
|
||||||
|
"TMPDIR": str(tmp_path), "HOME": str(tmp_path),
|
||||||
|
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@x",
|
||||||
|
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@x"}
|
||||||
|
|
||||||
|
|
||||||
|
def _repo(tmp_path, env):
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True, env=env)
|
||||||
|
(repo / "b.py").write_text("def one():\n return 1\n")
|
||||||
|
(repo / "c.py").write_text("def slug(t):\n return t.lower()\n")
|
||||||
|
subprocess.run(["git", "add", "."], cwd=repo, check=True, env=env)
|
||||||
|
subprocess.run(["git", "commit", "-q", "-m", "base"], cwd=repo, check=True, env=env)
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
def _run(repo, env, session="s-after-1", tool="Bash"):
|
||||||
|
out = subprocess.run(
|
||||||
|
["bash", str(HOOK)],
|
||||||
|
input=json.dumps({"session_id": session, "cwd": str(repo), "tool_name": tool,
|
||||||
|
"tool_input": {"command": "cat > x"}, "tool_response": {}}),
|
||||||
|
capture_output=True, text=True, env=env,
|
||||||
|
)
|
||||||
|
assert out.returncode == 0, out.stderr
|
||||||
|
return out.stdout
|
||||||
|
|
||||||
|
|
||||||
|
SINK_REPLY = b'{"context":"> family named","note_ids":[],"sync_note_ids":[],"derive_keys":["dup:483a"]}'
|
||||||
|
|
||||||
|
|
||||||
|
def test_after_write_names_what_bash_just_wrote_then_stays_quiet_until_the_next_change(tmp_path):
|
||||||
|
with http_sink(SINK_REPLY) as (port, seen):
|
||||||
|
env = _env(tmp_path, url=f"http://127.0.0.1:{port}")
|
||||||
|
repo = _repo(tmp_path, env)
|
||||||
|
# "A Bash call" wrote an untracked stylesheet and appended to a tracked file.
|
||||||
|
(repo / "a.css").write_text(".log-empty {\n color: red;\n}\n")
|
||||||
|
(repo / "b.py").write_text("def one():\n return 1\n\ndef slug(t):\n return t\n")
|
||||||
|
out = _run(repo, env)
|
||||||
|
by_path = {q["path"][0]: q for q in seen}
|
||||||
|
assert set(by_path) == {"a.css", "b.py"} # repo-relative, like the pre hook
|
||||||
|
assert by_path["a.css"]["shapes"] == ["css:log-empty"]
|
||||||
|
assert by_path["b.py"]["shapes"] == ["sym:slug"]
|
||||||
|
# Added lines only for the tracked file — the existing def is not "just written".
|
||||||
|
assert "def slug" in by_path["b.py"]["code"][0] and "def one" not in by_path["b.py"]["code"][0]
|
||||||
|
ctx = json.loads(out)["hookSpecificOutput"]
|
||||||
|
assert ctx["hookEventName"] == "PostToolUse"
|
||||||
|
assert "> family named" in ctx["additionalContext"]
|
||||||
|
# The local by-name arm rides along: `slug` already lives in c.py.
|
||||||
|
assert "`slug` is already defined in 1 other file(s): c.py" in ctx["additionalContext"]
|
||||||
|
# Derive keys landed on the SHARED channel the pre-write hook reads.
|
||||||
|
state = tmp_path / "scribe-priorart" / "s-after-1.derive.ids"
|
||||||
|
assert "dup:483a" in state.read_text().split()
|
||||||
|
|
||||||
|
# Nothing changed → one git status, no request, no output.
|
||||||
|
seen.clear()
|
||||||
|
assert _run(repo, env) == ""
|
||||||
|
assert seen == []
|
||||||
|
|
||||||
|
# Another change → only that file, and the dedup channel goes back up.
|
||||||
|
(repo / "a.css").write_text(".log-empty {\n color: red;\n}\n.other {\n margin: 0;\n}\n")
|
||||||
|
_run(repo, env)
|
||||||
|
assert [q["path"][0] for q in seen] == ["a.css"]
|
||||||
|
assert seen[0]["exclude_derive"] == ["dup:483a"]
|
||||||
|
assert set(seen[0]["shapes"][0].split(",")) == {"css:log-empty", "css:other"}
|
||||||
|
|
||||||
|
def test_after_write_is_silent_where_it_has_nothing_to_say(tmp_path):
|
||||||
|
env = _env(tmp_path)
|
||||||
|
repo = _repo(tmp_path, env)
|
||||||
|
# Not a Bash call → nothing (hooks.json matches Bash, the script re-checks).
|
||||||
|
(repo / "a.css").write_text(".x {\n color: red;\n}\n")
|
||||||
|
assert _run(repo, env, tool="Write") == ""
|
||||||
|
# Not a git repo → nothing.
|
||||||
|
loose = tmp_path / "loose"
|
||||||
|
loose.mkdir()
|
||||||
|
(loose / "a.css").write_text(".x {\n color: red;\n}\n")
|
||||||
|
assert _run(loose, env, session="s-loose") == ""
|
||||||
|
# A change that defines nothing (prose, a call-site edit) → nothing, even
|
||||||
|
# with the server unreachable (port 9 refuses): no definitions, no call
|
||||||
|
# owed, so not even the #2932 outage line. (a.css above is removed first:
|
||||||
|
# it DOES define a shape, and an unanswered call for it would rightly speak.)
|
||||||
|
(repo / "a.css").unlink()
|
||||||
|
(repo / "README.md").write_text("# notes\n")
|
||||||
|
(repo / "b.py").write_text("def one():\n return one_more()\n")
|
||||||
|
assert _run(repo, env, session="s-quiet") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_after_write_local_arm_works_without_a_server_and_says_the_server_did_not_answer(tmp_path):
|
||||||
|
"""The local by-name arm needs no instance (#2280). A configured instance
|
||||||
|
that does not ANSWER (a refused connection stands in for it) is said, once
|
||||||
|
per outage (#2932) — and the record nudge, which claims "nothing recorded",
|
||||||
|
is withheld: no answer backs that claim."""
|
||||||
|
env = _env(tmp_path)
|
||||||
|
repo = _repo(tmp_path, env)
|
||||||
|
(repo / "d.py").write_text("def slug(t):\n return t.lower()\n")
|
||||||
|
out = _run(repo, env, session="s-local")
|
||||||
|
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
||||||
|
assert "`slug` is already defined in 1 other file(s): c.py" in ctx
|
||||||
|
assert "Scribe did not answer the prior-art check for `d.py` within 8s" in ctx
|
||||||
|
assert "UNCHECKED" in ctx
|
||||||
|
assert "None of those existing copies is recorded" not in ctx
|
||||||
|
marker = tmp_path / "scribe-priorart" / "s-local.unreached"
|
||||||
|
assert marker.is_file() and marker.read_text().isdigit()
|
||||||
|
# Still down a moment later: the local arm speaks, the outage line does not
|
||||||
|
# repeat (once per outage, not once per write).
|
||||||
|
(repo / "e.py").write_text("def slug(t):\n return t.upper()\n")
|
||||||
|
out = _run(repo, env, session="s-local")
|
||||||
|
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
||||||
|
assert "`slug` is already defined in" in ctx
|
||||||
|
assert "did not answer" not in ctx
|
||||||
|
|
||||||
|
|
||||||
|
def test_after_write_unconfigured_install_owes_no_call_and_keeps_the_record_nudge(tmp_path):
|
||||||
|
"""No URL/token → no call was owed, so nothing is "unreached"; the local
|
||||||
|
arm and the record nudge (#2664) stand on their own, as before."""
|
||||||
|
env = {k: v for k, v in _env(tmp_path).items() if k not in ("SCRIBE_URL", "SCRIBE_TOKEN")}
|
||||||
|
repo = _repo(tmp_path, env)
|
||||||
|
(repo / "d.py").write_text("def slug(t):\n return t.lower()\n")
|
||||||
|
out = _run(repo, env, session="s-unconf")
|
||||||
|
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
|
||||||
|
assert "`slug` is already defined in 1 other file(s): c.py" in ctx
|
||||||
|
assert "create_snippet" in ctx
|
||||||
|
assert "did not answer" not in ctx
|
||||||
|
assert not (tmp_path / "scribe-priorart" / "s-unconf.unreached").exists()
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Project inception (milestone 297) — step 1: the record's shape.
|
||||||
|
|
||||||
|
The WHY a project inherits what it does lives on projects.inception; the
|
||||||
|
opt-out of an always-on rulebook is its own association table. Pure
|
||||||
|
validation is pinned here; the effects are step 3's integration tests.
|
||||||
|
"""
|
||||||
|
from scribe.models import Base
|
||||||
|
from scribe.models.project import Project
|
||||||
|
from scribe.models.rulebook import project_rulebook_exclusions
|
||||||
|
from scribe.services.inception import (
|
||||||
|
CHOICE_KEYS, INCEPTION_VIAS, is_decided, normalize_choices, validate_inception,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_carries_an_inception_record_and_to_dict_shows_it():
|
||||||
|
assert "inception" in Project.__table__.c
|
||||||
|
assert Project.__table__.c.inception.nullable # NULL = undecided
|
||||||
|
p = Project(user_id=1, title="x", inception=None)
|
||||||
|
assert p.to_dict()["inception"] is None and not is_decided(p)
|
||||||
|
p.inception = {"via": "mcp", "decided_at": "2026-08-22T00:00:00+00:00", "decided_by": 1,
|
||||||
|
"choices": normalize_choices({"seed_systems": True})}
|
||||||
|
assert is_decided(p) and p.to_dict()["inception"]["via"] == "mcp"
|
||||||
|
assert INCEPTION_VIAS == ("mcp", "ui", "legacy")
|
||||||
|
|
||||||
|
|
||||||
|
def test_exclusions_table_is_the_suppressions_sibling():
|
||||||
|
t = Base.metadata.tables["project_rulebook_exclusions"]
|
||||||
|
assert project_rulebook_exclusions is t
|
||||||
|
assert {c.name for c in t.primary_key.columns} == {"project_id", "rulebook_id"}
|
||||||
|
fks = {fk.column.table.name: fk.ondelete for c in t.columns for fk in c.foreign_keys}
|
||||||
|
assert fks == {"projects": "CASCADE", "rulebooks": "CASCADE"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_inception_pins_the_choice_vocabulary():
|
||||||
|
assert CHOICE_KEYS == ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems")
|
||||||
|
assert validate_inception({}) is None
|
||||||
|
assert validate_inception({"exclude_always_on_rulebooks": [1], "subscribe_rulebooks": [2],
|
||||||
|
"design_system_id": 3, "seed_systems": True}) is None
|
||||||
|
assert validate_inception({"design_system_id": None}) is None
|
||||||
|
assert "must be an object" in validate_inception([])
|
||||||
|
assert "unknown inception choice" in validate_inception({"repo": "x"})
|
||||||
|
assert "list of rulebook ids" in validate_inception({"exclude_always_on_rulebooks": "1"})
|
||||||
|
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [0]})
|
||||||
|
assert "list of rulebook ids" in validate_inception({"subscribe_rulebooks": [True]})
|
||||||
|
assert "both excluded and subscribed" in validate_inception(
|
||||||
|
{"exclude_always_on_rulebooks": [1, 2], "subscribe_rulebooks": [2]})
|
||||||
|
assert "positive id or null" in validate_inception({"design_system_id": 0})
|
||||||
|
assert "positive id or null" in validate_inception({"design_system_id": True})
|
||||||
|
assert "true or false" in validate_inception({"seed_systems": "yes"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_choices_is_canonical_and_complete():
|
||||||
|
out = normalize_choices({"subscribe_rulebooks": [3, 1, 3], "exclude_always_on_rulebooks": [2]})
|
||||||
|
assert out == {"exclude_always_on_rulebooks": [2], "subscribe_rulebooks": [1, 3],
|
||||||
|
"design_system_id": None, "seed_systems": False}
|
||||||
|
assert normalize_choices(None) == {"exclude_always_on_rulebooks": [], "subscribe_rulebooks": [],
|
||||||
|
"design_system_id": None, "seed_systems": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant():
|
||||||
|
"""The vocabulary moved from a module constant to the global catalog table
|
||||||
|
(milestone 307): a constant cannot be a foreign key, so nothing outside a
|
||||||
|
project could reference an area. The seed and the bootstrap ask must both
|
||||||
|
read the table, or the list they show and the list they mint diverge."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from scribe.services import systems as systems_svc
|
||||||
|
|
||||||
|
assert not hasattr(systems_svc, "STANDARD_SYSTEMS"), (
|
||||||
|
"the constant is gone — the catalog table is the single source"
|
||||||
|
)
|
||||||
|
source = inspect.getsource(systems_svc.seed_standard_systems)
|
||||||
|
assert "list_canonical_systems" in source
|
||||||
|
assert "canonical_id=entry.id" in source, (
|
||||||
|
"a seeded System must be mapped as it is created, or a project born "
|
||||||
|
"from the standard set still needs a reconciliation pass"
|
||||||
|
)
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Milestone 297 step 2 — always-on exclusions reach every rule surface.
|
||||||
|
|
||||||
|
The SQL is the integration lane's; here the contracts: rules_payload carries
|
||||||
|
the seventh key, list_always_on_rules takes project_id, the session-start
|
||||||
|
block names the excluded rulebooks, and the MCP tools mount.
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scribe.services.rulebooks import rules_payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_rules_payload_carries_excluded_always_on_as_the_seventh_key():
|
||||||
|
out = rules_payload({
|
||||||
|
"rules": [], "truncated": False, "subscribed_rulebooks": [],
|
||||||
|
"excluded_always_on": [{"id": 1, "title": "Family"}],
|
||||||
|
})
|
||||||
|
assert set(out) == {
|
||||||
|
"applicable_rules", "applicable_rules_truncated", "subscribed_rulebooks",
|
||||||
|
"project_rules", "suppressed_rules", "suppressed_topics", "excluded_always_on",
|
||||||
|
}
|
||||||
|
assert out["excluded_always_on"] == [{"id": 1, "title": "Family"}]
|
||||||
|
# An older applicable dict without the key still renders (empty list).
|
||||||
|
assert rules_payload({"rules": [], "truncated": False, "subscribed_rulebooks": []})["excluded_always_on"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_always_on_rules_service_and_tool_take_a_project_id():
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from scribe.mcp.tools import rulebooks as tools
|
||||||
|
from scribe.services import rulebooks as svc
|
||||||
|
assert "project_id" in inspect.signature(svc.list_always_on_rules).parameters
|
||||||
|
assert "project_id" in inspect.signature(tools.list_always_on_rules).parameters
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_context_names_the_excluded_always_on_rulebooks():
|
||||||
|
from types import SimpleNamespace as NS
|
||||||
|
|
||||||
|
from scribe.services.plugin_context import build_session_context
|
||||||
|
rules = [NS(id=1, title="`dev` is home", topic_id=1, statement="x")]
|
||||||
|
project = NS(id=9, title="Widget", goal="", design_system_id=None)
|
||||||
|
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
||||||
|
AsyncMock(return_value=rules)) as lao, \
|
||||||
|
patch("scribe.services.plugin_context.rulebooks_svc.excluded_always_on_rulebooks",
|
||||||
|
AsyncMock(return_value=[{"id": 5, "title": "Design standards"}])), \
|
||||||
|
patch("scribe.services.plugin_context._topic_titles", AsyncMock(return_value={1: "git"})), \
|
||||||
|
patch("scribe.services.plugin_context.projects_svc.get_project", AsyncMock(return_value=project)), \
|
||||||
|
patch("scribe.services.plugin_context.notes_svc.list_notes", AsyncMock(return_value=([], 0))), \
|
||||||
|
patch("scribe.services.plugin_context.rulebooks_svc.get_applicable_rules",
|
||||||
|
AsyncMock(return_value={"rules": [], "truncated": False, "subscribed_rulebooks": [],
|
||||||
|
"project_rules": [], "suppressed_rules": [],
|
||||||
|
"suppressed_topics": [], "excluded_always_on": []})):
|
||||||
|
out = await build_session_context(user_id=7, project_id=9)
|
||||||
|
# The always-on set was asked FOR THIS PROJECT, and the departure is named.
|
||||||
|
assert lao.await_args.kwargs.get("project_id") == 9
|
||||||
|
assert "Excluded for this project by its inception decision" in out["context"]
|
||||||
|
assert "Design standards (#5)" in out["context"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_exclusion_routes_are_registered():
|
||||||
|
from scribe.app import create_app
|
||||||
|
rules = {r.rule for r in create_app().url_map.iter_rules()}
|
||||||
|
assert "/api/projects/<int:project_id>/exclusions/rulebooks/<int:rulebook_id>" in rules
|
||||||
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Real-Postgres integration tests for project inception (milestone 297).
|
||||||
|
|
||||||
|
What mocks can't prove: a decision's effects land through the real services
|
||||||
|
(exclusions filter the always-on set, subscriptions bind, the design system
|
||||||
|
points, the standard Systems seed once), the record is written last, a bad
|
||||||
|
target applies nothing.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
from scribe.models import async_session
|
||||||
|
from scribe.models.project import Project
|
||||||
|
from scribe.models.rulebook import Rulebook
|
||||||
|
from scribe.services import inception as inception_svc
|
||||||
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
|
from scribe.services import canonical_systems as canonical_svc
|
||||||
|
from scribe.services import systems as systems_svc
|
||||||
|
from tests.helpers import ensure_user
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def seeded():
|
||||||
|
"""Owner, a fresh project, one always-on rulebook (with a rule) and one
|
||||||
|
ordinary rulebook (with a rule)."""
|
||||||
|
async with async_session() as s:
|
||||||
|
owner = await ensure_user(s, "inception_owner")
|
||||||
|
project = Project(user_id=owner.id, title="Inception target")
|
||||||
|
s.add(project)
|
||||||
|
await s.flush()
|
||||||
|
ids = {"owner": owner.id, "pid": project.id}
|
||||||
|
await s.commit()
|
||||||
|
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
|
||||||
|
other = await rulebooks_svc.create_rulebook(ids["owner"], "Optional practices")
|
||||||
|
async with async_session() as s:
|
||||||
|
rb = await s.get(Rulebook, always.id)
|
||||||
|
rb.always_on = True
|
||||||
|
await s.commit()
|
||||||
|
t1 = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
|
||||||
|
await rulebooks_svc.create_rule(t1.id, ids["owner"], "dev is home", "Work on dev.")
|
||||||
|
t2 = await rulebooks_svc.create_topic(other.id, ids["owner"], "docs")
|
||||||
|
await rulebooks_svc.create_rule(t2.id, ids["owner"], "Write the why", "Record reasons.")
|
||||||
|
ids.update({"always": always.id, "other": other.id})
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_decide_applies_every_effect_and_records_last(seeded):
|
||||||
|
owner, pid = seeded["owner"], seeded["pid"]
|
||||||
|
# Undecided: the always-on rulebook binds, nothing subscribed, no Systems.
|
||||||
|
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
|
||||||
|
defaults = await inception_svc.current_defaults(owner, pid)
|
||||||
|
assert [r["id"] for r in defaults["always_on_rulebooks"]] == [seeded["always"]]
|
||||||
|
assert [r["id"] for r in defaults["other_rulebooks"]] == [seeded["other"]]
|
||||||
|
assert defaults["systems"] == 0 and defaults["design_system_id"] is None
|
||||||
|
|
||||||
|
out = await inception_svc.decide(owner, pid, via="mcp", choices={
|
||||||
|
"exclude_always_on_rulebooks": [seeded["always"]],
|
||||||
|
"subscribe_rulebooks": [seeded["other"]],
|
||||||
|
"design_system_id": None,
|
||||||
|
"seed_systems": True,
|
||||||
|
})
|
||||||
|
assert out["effects"]["excluded"] == [seeded["always"]]
|
||||||
|
assert out["effects"]["subscribed"] == [seeded["other"]]
|
||||||
|
catalog = await canonical_svc.list_canonical_systems()
|
||||||
|
assert len(out["effects"]["systems_seeded"]) == len(catalog)
|
||||||
|
# Seeded Systems come out mapped, not needing a later reconciliation.
|
||||||
|
seeded_systems = await systems_svc.list_systems(owner, pid)
|
||||||
|
assert all(s.canonical_id is not None for s in seeded_systems)
|
||||||
|
|
||||||
|
# The exclusion is total: the project's always-on set is empty, the
|
||||||
|
# departure is named, the subscription binds.
|
||||||
|
assert await rulebooks_svc.list_always_on_rules(owner, project_id=pid) == []
|
||||||
|
assert len(await rulebooks_svc.list_always_on_rules(owner)) == 1 # user-wide unchanged
|
||||||
|
applicable = await rulebooks_svc.get_applicable_rules(pid, owner)
|
||||||
|
assert [r["title"] for r in applicable["rules"]] == ["Write the why"]
|
||||||
|
assert [e["id"] for e in applicable["excluded_always_on"]] == [seeded["always"]]
|
||||||
|
assert [s["id"] for s in applicable["subscribed_rulebooks"]] == [seeded["other"]]
|
||||||
|
# The record, written last, says why.
|
||||||
|
async with async_session() as s:
|
||||||
|
project = await s.get(Project, pid)
|
||||||
|
assert inception_svc.is_decided(project)
|
||||||
|
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
|
||||||
|
assert project.inception["choices"]["exclude_always_on_rulebooks"] == [seeded["always"]]
|
||||||
|
# Re-deciding with seed again mints nothing twice; include reverses the exclusion.
|
||||||
|
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
|
||||||
|
assert again["effects"]["systems_seeded"] == []
|
||||||
|
assert len(await systems_svc.list_systems(owner, pid)) == len(catalog)
|
||||||
|
await rulebooks_svc.include_always_on_rulebook_for_project(pid, seeded["always"], owner)
|
||||||
|
assert [r.title for r in await rulebooks_svc.list_always_on_rules(owner, project_id=pid)] == ["dev is home"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_a_bad_decision_applies_nothing(seeded):
|
||||||
|
owner, pid = seeded["owner"], seeded["pid"]
|
||||||
|
# Excluding a rulebook that is not always-on is refused BEFORE any effect.
|
||||||
|
with pytest.raises(ValueError, match="not always-on"):
|
||||||
|
await inception_svc.decide(owner, pid, via="mcp", choices={
|
||||||
|
"exclude_always_on_rulebooks": [seeded["other"]], "seed_systems": True,
|
||||||
|
})
|
||||||
|
assert await systems_svc.list_systems(owner, pid) == []
|
||||||
|
with pytest.raises(ValueError, match="not found"):
|
||||||
|
await inception_svc.decide(owner, pid, via="mcp", choices={"subscribe_rulebooks": [999999]})
|
||||||
|
with pytest.raises(ValueError, match="legacy"):
|
||||||
|
await inception_svc.decide(owner, pid, via="legacy", choices={})
|
||||||
|
async with async_session() as s:
|
||||||
|
project = await s.get(Project, pid)
|
||||||
|
assert not inception_svc.is_decided(project)
|
||||||
|
# An outsider cannot decide someone else's project.
|
||||||
|
async with async_session() as s:
|
||||||
|
other = await ensure_user(s, "inception_other")
|
||||||
|
other_id = other.id
|
||||||
|
await s.commit()
|
||||||
|
with pytest.raises(ValueError, match="not found"):
|
||||||
|
await inception_svc.decide(other_id, pid, via="mcp", choices={})
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"""Real-Postgres tests for WHICH rules reach a session (milestone 307 step 5).
|
||||||
|
|
||||||
|
What mocks can't prove, and what this milestone must not get wrong:
|
||||||
|
|
||||||
|
1. **Nothing stops binding.** A rule with no tier, no areas and no edges
|
||||||
|
behaves exactly as it did before tiers existed. That is the one failure this
|
||||||
|
whole design must not produce, and it is asserted first.
|
||||||
|
2. A conditional rule is invisible to a project that doesn't work in its area,
|
||||||
|
and arrives — binding, not suggested — to one that does.
|
||||||
|
3. A `co_surfaces` partner arrives with its other half, which is the failure
|
||||||
|
that made merging rule 144 into rule 46 look like the only fix.
|
||||||
|
4. An explicit suppression outranks an edge.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
from scribe.models import async_session
|
||||||
|
from scribe.models.project import Project
|
||||||
|
from scribe.models.rulebook import Rulebook
|
||||||
|
from scribe.services import canonical_systems as canonical_svc
|
||||||
|
from scribe.services import rulebooks as rulebooks_svc
|
||||||
|
from scribe.services import systems as systems_svc
|
||||||
|
from tests.helpers import ensure_user
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def world():
|
||||||
|
"""A project with TWO rulebooks, because the two payloads are different sets.
|
||||||
|
|
||||||
|
`list_always_on_rules` covers always-on rulebooks; `get_applicable_rules`
|
||||||
|
covers SUBSCRIBED ones. Conflating them is easy and would make these tests
|
||||||
|
assert nothing, so the fixture carries one of each and every test says
|
||||||
|
which payload it is about.
|
||||||
|
"""
|
||||||
|
async with async_session() as s:
|
||||||
|
owner = await ensure_user(s, "surfacing_owner")
|
||||||
|
project = Project(user_id=owner.id, title="Surfacing target")
|
||||||
|
s.add(project)
|
||||||
|
await s.flush()
|
||||||
|
ids = {"owner": owner.id, "pid": project.id}
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
always = await rulebooks_svc.create_rulebook(ids["owner"], "Family standards")
|
||||||
|
async with async_session() as s:
|
||||||
|
rb = await s.get(Rulebook, always.id)
|
||||||
|
rb.always_on = True
|
||||||
|
await s.commit()
|
||||||
|
always_topic = await rulebooks_svc.create_topic(always.id, ids["owner"], "git")
|
||||||
|
await rulebooks_svc.create_rule(
|
||||||
|
always_topic.id, ids["owner"], "dev is home", "Work on dev.",
|
||||||
|
)
|
||||||
|
|
||||||
|
book = await rulebooks_svc.create_rulebook(ids["owner"], "Subscribed practices")
|
||||||
|
topic = await rulebooks_svc.create_topic(book.id, ids["owner"], "release")
|
||||||
|
plain = await rulebooks_svc.create_rule(
|
||||||
|
topic.id, ids["owner"], "Between batches, keep stacking", "Keep going.",
|
||||||
|
)
|
||||||
|
await rulebooks_svc.subscribe_project(
|
||||||
|
project_id=ids["pid"], rulebook_id=book.id, user_id=ids["owner"],
|
||||||
|
)
|
||||||
|
ids.update({
|
||||||
|
"always": always.id, "always_topic": always_topic.id,
|
||||||
|
"book": book.id, "topic": topic.id, "plain": plain.id,
|
||||||
|
})
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
async def _titles(ids) -> set[str]:
|
||||||
|
applicable = await rulebooks_svc.get_applicable_rules(ids["pid"], ids["owner"])
|
||||||
|
return {r["title"] for r in applicable["rules"]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_a_rule_with_no_tier_no_areas_and_no_edges_binds_exactly_as_before(world):
|
||||||
|
"""THE compatibility guarantee. An install upgrades and every rule it
|
||||||
|
already had keeps arriving — no tier set, no areas, no edges, still bound.
|
||||||
|
Getting this wrong would silently stop enforcing rules people rely on,
|
||||||
|
which is worse than any amount of payload bloat."""
|
||||||
|
always_on = await rulebooks_svc.list_always_on_rules(world["owner"])
|
||||||
|
assert "dev is home" in {r.title for r in always_on}
|
||||||
|
assert "Between batches, keep stacking" in await _titles(world)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_a_conditional_rule_is_reachable_not_resident(world):
|
||||||
|
"""It leaves the session-start payload entirely — that is the point of the
|
||||||
|
tier — and it does NOT reach a project with no matching area."""
|
||||||
|
# In the ALWAYS-ON book: the tier alone keeps it out of the session-start
|
||||||
|
# payload, which is the whole point of the tier.
|
||||||
|
resident = await rulebooks_svc.create_rule(
|
||||||
|
world["always_topic"], world["owner"], "Release tagging", "Derive the tag.",
|
||||||
|
when_to_apply="when cutting a release", tier="conditional",
|
||||||
|
)
|
||||||
|
assert resident.tier == "conditional"
|
||||||
|
always_on = await rulebooks_svc.list_always_on_rules(world["owner"])
|
||||||
|
assert "Release tagging" not in {r.title for r in always_on}
|
||||||
|
|
||||||
|
# In the SUBSCRIBED book, untagged: the project has no area to reach it by,
|
||||||
|
# so it stays out of the project payload too. Absent for a DIFFERENT reason
|
||||||
|
# than above, which is why both are asserted.
|
||||||
|
await rulebooks_svc.create_rule(
|
||||||
|
world["topic"], world["owner"], "Untagged conditional", "No area yet.",
|
||||||
|
when_to_apply="sometime", tier="conditional",
|
||||||
|
)
|
||||||
|
assert "Untagged conditional" not in await _titles(world)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_a_conditional_rule_binds_a_project_that_works_in_its_area(world):
|
||||||
|
"""The payoff: the tag match carries it in deterministically. The project
|
||||||
|
reaches the area through its own System's canonical_id — its local NAME is
|
||||||
|
irrelevant, which is the whole reason the catalog exists."""
|
||||||
|
area = await canonical_svc.find_by_name("CI & Release")
|
||||||
|
assert area is not None, "migration 0087 seeds the standard vocabulary"
|
||||||
|
|
||||||
|
rule = await rulebooks_svc.create_rule(
|
||||||
|
world["topic"], world["owner"], "Release tagging", "Derive the tag.",
|
||||||
|
when_to_apply="when cutting a release", tier="conditional",
|
||||||
|
)
|
||||||
|
await rulebooks_svc.set_rule_systems(rule.id, world["owner"], [area.id])
|
||||||
|
|
||||||
|
# Still absent: the project has no Systems at all yet.
|
||||||
|
assert "Release tagging" not in await _titles(world)
|
||||||
|
|
||||||
|
# The project names the area with its OWN word, mapped to the same canon.
|
||||||
|
local = await systems_svc.create_system(
|
||||||
|
world["owner"], world["pid"], "CI & runners", description="ours",
|
||||||
|
)
|
||||||
|
await canonical_svc.set_system_canonical(world["owner"], local.id, area.id)
|
||||||
|
|
||||||
|
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
|
||||||
|
hit = [r for r in surfaced["rules"] if r["title"] == "Release tagging"]
|
||||||
|
assert hit, "a tagged conditional rule must bind a project working in that area"
|
||||||
|
assert [s["name"] for s in hit[0]["systems"]] == ["CI & Release"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_co_surfaces_drags_in_the_half_that_would_have_been_missed(world):
|
||||||
|
"""Rule 144 was split off rule 46 and folded back the same day because
|
||||||
|
"either rule could surface without the other". This is the edge that makes
|
||||||
|
that unnecessary: the partner arrives even though nothing else selected
|
||||||
|
it, and says why it is here."""
|
||||||
|
partner = await rulebooks_svc.create_rule(
|
||||||
|
world["topic"], world["owner"], "Version names are labels",
|
||||||
|
"A name decides nothing.",
|
||||||
|
when_to_apply="when naming a build", tier="conditional",
|
||||||
|
)
|
||||||
|
await rulebooks_svc.add_rule_relation(
|
||||||
|
world["owner"], world["plain"], partner.id, "co_surfaces",
|
||||||
|
note="they fail together",
|
||||||
|
)
|
||||||
|
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
|
||||||
|
hit = [r for r in surfaced["rules"] if r["title"] == "Version names are labels"]
|
||||||
|
assert hit, "a co_surfaces partner must arrive with its other half"
|
||||||
|
assert hit[0]["via"] == "co_surfaces"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_a_suppression_outranks_an_edge(world):
|
||||||
|
"""The edge says these belong together; the suppression says this project
|
||||||
|
does not want that one. An explicit decision beats an inferred one."""
|
||||||
|
partner = await rulebooks_svc.create_rule(
|
||||||
|
world["topic"], world["owner"], "Muted partner", "Should not arrive.",
|
||||||
|
tier="conditional",
|
||||||
|
)
|
||||||
|
await rulebooks_svc.add_rule_relation(
|
||||||
|
world["owner"], world["plain"], partner.id, "co_surfaces",
|
||||||
|
)
|
||||||
|
await rulebooks_svc.suppress_rule_for_project(
|
||||||
|
world["pid"], partner.id, world["owner"],
|
||||||
|
)
|
||||||
|
surfaced = await rulebooks_svc.get_applicable_rules(world["pid"], world["owner"])
|
||||||
|
assert "Muted partner" not in {r["title"] for r in surfaced["rules"]}
|
||||||
@@ -588,13 +588,213 @@ async def test_derive_groups_land_on_rows_and_in_the_summary(seeded):
|
|||||||
assert summary["derive_groups"][1]["label"] == ".card"
|
assert summary["derive_groups"][1]["label"] == ".card"
|
||||||
assert summary["derive_groups"][0]["size"] == 2 and summary["derive_groups"][1]["size"] == 3
|
assert summary["derive_groups"][0]["size"] == 2 and summary["derive_groups"][1]["size"] == 3
|
||||||
|
|
||||||
# One of the css copies gets judged → the group shrinks on the next pass.
|
# One of the css copies gets judged → the group shrinks on the next pass
|
||||||
|
# but stays a family: a class in two files is already a recipe living in
|
||||||
|
# two places (css name floor 2, note 2917). Judge the second and it's gone.
|
||||||
await classify_shapes(owner, pid, [
|
await classify_shapes(owner, pid, [
|
||||||
{"path": "b/z.css", "symbol": "card", "status": "exempt", "reason": "print sheet"},
|
{"path": "b/z.css", "symbol": "card", "status": "exempt", "reason": "print sheet"},
|
||||||
])
|
])
|
||||||
await apply_derive_groups(pid)
|
await apply_derive_groups(pid)
|
||||||
rows, _ = await list_project_shapes(owner, pid, proposal="derive")
|
rows, _ = await list_project_shapes(owner, pid, proposal="derive")
|
||||||
assert {r.symbol for r in rows} == {"slug"} # 2 files < the name floor
|
assert {r.symbol for r in rows} == {"slug", "card"}
|
||||||
|
assert {r.path for r in rows if r.symbol == "card"} == {"b/x.css", "b/y.css"}
|
||||||
|
await classify_shapes(owner, pid, [
|
||||||
|
{"path": "b/y.css", "symbol": "card", "status": "exempt", "reason": "print sheet"},
|
||||||
|
])
|
||||||
|
await apply_derive_groups(pid)
|
||||||
|
rows, _ = await list_project_shapes(owner, pid, proposal="derive")
|
||||||
|
assert {r.symbol for r in rows} == {"slug"} # 1 file < the css name floor
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_write_time_derive_names_the_family_or_the_canon_for_a_name(seeded):
|
||||||
|
"""#2900: against real rows — a name in a family → the family (other
|
||||||
|
files, count; for CSS a NAME family, never a body one — note 2917); a
|
||||||
|
name whose canonical row lives elsewhere → that canon; a judged row at
|
||||||
|
the path, the canon's own file, or an unknown name → silence."""
|
||||||
|
from scribe.services.shape_ledger import apply_derive_groups, write_time_derive
|
||||||
|
|
||||||
|
owner, pid, sid = seeded["owner"], seeded["pid"], seeded["snippet"]
|
||||||
|
defs = _defs(
|
||||||
|
("v/A.vue", "css", "log-empty", ".log-empty {", ".log-empty { color: red }"),
|
||||||
|
("v/B.vue", "css", "log-empty", ".log-empty {", ".log-empty { color: red }"),
|
||||||
|
("v/C.vue", "css", "log-empty", ".log-empty {", ".log-empty { color: red }"),
|
||||||
|
("src/factory.py", "sym", "factory", "def factory():", "def factory():\n return 1"),
|
||||||
|
)
|
||||||
|
await sync_repo_shapes(pid, REPO, defs, seen_marker="m1")
|
||||||
|
await classify_shapes(owner, pid, [
|
||||||
|
{"path": "src/factory.py", "symbol": "factory", "status": "canonical", "snippet_id": sid},
|
||||||
|
])
|
||||||
|
assert await apply_derive_groups(pid) >= 3
|
||||||
|
|
||||||
|
# A 4th copy about to be written → the family, naming the other files.
|
||||||
|
out = await write_time_derive(pid, "v/D.vue", [("css", "log-empty"), ("css", "unknown")])
|
||||||
|
assert len(out) == 1 and out[0]["symbol"] == "log-empty" and out[0]["kind"] == "css"
|
||||||
|
fam = out[0]["family"]
|
||||||
|
# Three identical bodies, and still a NAME family: CSS never groups by body.
|
||||||
|
assert fam["identical"] is False and fam["label"] == ".log-empty"
|
||||||
|
assert fam["files"] == ["v/A.vue", "v/B.vue", "v/C.vue"] and fam["file_count"] == 3
|
||||||
|
assert out[0]["key"] == fam["group"] == "name:css:log-empty"
|
||||||
|
# Editing one existing member still names the OTHER members.
|
||||||
|
out = await write_time_derive(pid, "v/A.vue", [("css", "log-empty")])
|
||||||
|
assert out[0]["family"]["files"] == ["v/B.vue", "v/C.vue"] and out[0]["family"]["size"] == 3
|
||||||
|
# The canon's name elsewhere → the canon; in the canon's own file → silence.
|
||||||
|
out = await write_time_derive(pid, "src/other.py", [("sym", "factory")])
|
||||||
|
assert out == [{"symbol": "factory", "kind": "sym", "key": f"canon:{sid}",
|
||||||
|
"canon": {"snippet_id": sid, "path": "src/factory.py", "label": "factory"}}]
|
||||||
|
assert await write_time_derive(pid, "src/factory.py", [("sym", "factory")]) == []
|
||||||
|
# A judged row at the path is not re-litigated.
|
||||||
|
await classify_shapes(owner, pid, [
|
||||||
|
{"path": "v/B.vue", "symbol": "log-empty", "status": "exempt", "reason": "print sheet"},
|
||||||
|
])
|
||||||
|
assert await write_time_derive(pid, "v/B.vue", [("css", "log-empty")]) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_derive_new_names_the_copy_that_joined_a_family_since_the_stamp(seeded):
|
||||||
|
"""#2899: the first sync seeds one `slug`; a later sync adds an identical
|
||||||
|
copy. Against the stamp between them, derive_new counts ONLY the
|
||||||
|
newcomer — the drift since the last refresh, not the whole family."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from scribe.services.shape_ledger import (
|
||||||
|
apply_derive_groups, derive_new_summary, live_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
owner, pid = seeded["owner"], seeded["pid"]
|
||||||
|
first = _defs(
|
||||||
|
("a/one.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"),
|
||||||
|
)
|
||||||
|
await sync_repo_shapes(pid, REPO, first, seen_marker="m1")
|
||||||
|
stamp = datetime.now(timezone.utc)
|
||||||
|
second = _defs(
|
||||||
|
("a/one.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"),
|
||||||
|
("a/two.py", "sym", "slug", "def slug(t):", "def slug(t):\n return t.lower()"),
|
||||||
|
)
|
||||||
|
await sync_repo_shapes(pid, REPO, second, seen_marker="m2")
|
||||||
|
assert await apply_derive_groups(pid) == 2
|
||||||
|
|
||||||
|
rows = await live_rows(pid)
|
||||||
|
out = derive_new_summary(rows, since=stamp)
|
||||||
|
assert out["count"] == 1
|
||||||
|
assert out["examples"][0]["path"] == "a/two.py"
|
||||||
|
assert out["examples"][0]["label"] == "slug"
|
||||||
|
assert out["examples"][0]["group"].startswith("dup:")
|
||||||
|
assert derive_new_summary(rows, since=None)["count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_transition_and_prefix_references_clear_the_unused_css_flag(seeded):
|
||||||
|
"""#2970 end to end: the two class forms a template never spells out —
|
||||||
|
a transition `name=` and a concatenated name — travel from the real
|
||||||
|
extractor through resolution into the flag, so rules they reach stop
|
||||||
|
being reported as unused. Only a rule nothing can reach stays listed."""
|
||||||
|
from scribe.services.coverage import class_references
|
||||||
|
from scribe.services.shape_ledger import sync_repo_consumers, used_by_map, live_rows
|
||||||
|
|
||||||
|
pid, owner = seeded["pid"], seeded["owner"]
|
||||||
|
defs = _defs(
|
||||||
|
("assets/anim.css", "css", "toast-enter-active",
|
||||||
|
".toast-enter-active {", ".toast-enter-active { opacity: 0 }"),
|
||||||
|
("assets/anim.css", "css", "toast-leave-to",
|
||||||
|
".toast-leave-to {", ".toast-leave-to { opacity: 0 }"),
|
||||||
|
("assets/state.css", "css", "status-done",
|
||||||
|
".status-done {", ".status-done { color: green }"),
|
||||||
|
("assets/state.css", "css", "status-todo",
|
||||||
|
".status-todo {", ".status-todo { color: grey }"),
|
||||||
|
("assets/state.css", "css", "really-dead",
|
||||||
|
".really-dead {", ".really-dead { color: red }"),
|
||||||
|
)
|
||||||
|
await sync_repo_shapes(pid, REPO, defs, seen_marker="t1")
|
||||||
|
|
||||||
|
refs = {
|
||||||
|
# Vue applies the toast-* classes itself; the markup names none of them.
|
||||||
|
"v/Toast.vue": class_references(
|
||||||
|
"v/Toast.vue", '<transition-group name="toast"><li /></transition-group>'
|
||||||
|
),
|
||||||
|
# The board builds its status class; which one is unknowable.
|
||||||
|
"v/Board.vue": class_references("v/Board.vue", '<b :class="`status-${s}`" />'),
|
||||||
|
}
|
||||||
|
assert "toast-enter-active" in refs["v/Toast.vue"]
|
||||||
|
assert refs["v/Board.vue"] == {"status-*": 1}
|
||||||
|
|
||||||
|
await sync_repo_consumers(pid, REPO, refs)
|
||||||
|
live = [r for r in await live_rows(pid) if r.kind == "css"]
|
||||||
|
used = await used_by_map(live)
|
||||||
|
by = {(r.path, r.symbol): used[r.id]["count"] for r in live}
|
||||||
|
assert by[("assets/anim.css", "toast-enter-active")] == 1
|
||||||
|
assert by[("assets/anim.css", "toast-leave-to")] == 1
|
||||||
|
# the prefix credits BOTH candidates — the template does not say which
|
||||||
|
assert by[("assets/state.css", "status-done")] == 1
|
||||||
|
assert by[("assets/state.css", "status-todo")] == 1
|
||||||
|
assert by[("assets/state.css", "really-dead")] == 0
|
||||||
|
|
||||||
|
unused, n = await list_project_shapes(owner, pid, flag="unused-css")
|
||||||
|
assert n == 1 and [(r.path, r.symbol) for r in unused] == [
|
||||||
|
("assets/state.css", "really-dead")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_consumer_map_syncs_edges_from_template_references(seeded):
|
||||||
|
"""Milestone 302: the consumer edges follow the archive — own-file
|
||||||
|
resolution for a scoped class, fan-out to the shared sheet for a class a
|
||||||
|
template does not define, counts refreshed and stale edges removed on
|
||||||
|
the next sync, and a vanished row's edges gone with it."""
|
||||||
|
from scribe.services.shape_ledger import consumers_of, live_rows, sync_repo_consumers
|
||||||
|
|
||||||
|
pid = seeded["pid"]
|
||||||
|
defs = _defs(
|
||||||
|
("v/A.vue", "css", "error-msg", ".error-msg {", ".error-msg { color: red }"),
|
||||||
|
("v/B.vue", "css", "error-msg", ".error-msg {", ".error-msg { color: blue }"),
|
||||||
|
("assets/components.css", "css", "btn-primary", ".btn-primary {", ".btn-primary { x: 1 }"),
|
||||||
|
("assets/orphan.css", "css", "orphan", ".orphan {", ".orphan { y: 2 }"), # no template names it
|
||||||
|
)
|
||||||
|
await sync_repo_shapes(pid, REPO, defs, seen_marker="m1")
|
||||||
|
refs = {
|
||||||
|
"v/A.vue": {"error-msg": 2, "btn-primary": 1},
|
||||||
|
"v/B.vue": {"error-msg": 1},
|
||||||
|
"v/C.vue": {"error-msg": 1, "btn-primary": 4},
|
||||||
|
}
|
||||||
|
# A and B consume their OWN error-msg; C defines none, so its use fans
|
||||||
|
# out to both rows; btn-primary resolves to the shared sheet from A and C.
|
||||||
|
assert await sync_repo_consumers(pid, REPO, refs) == 6
|
||||||
|
rows = {(r.path, r.symbol): r.id for r in await live_rows(pid) if r.kind == "css"}
|
||||||
|
edges = await consumers_of(rows.values())
|
||||||
|
view = {(p, s): [(e.path, e.count) for e in edges.get(i, [])] for (p, s), i in rows.items()}
|
||||||
|
assert view[("v/A.vue", "error-msg")] == [("v/A.vue", 2), ("v/C.vue", 1)]
|
||||||
|
assert view[("v/B.vue", "error-msg")] == [("v/B.vue", 1), ("v/C.vue", 1)]
|
||||||
|
assert view[("assets/components.css", "btn-primary")] == [("v/A.vue", 1), ("v/C.vue", 4)]
|
||||||
|
assert view[("assets/orphan.css", "orphan")] == []
|
||||||
|
|
||||||
|
# The next tree: C stops using error-msg, A uses btn-primary twice now.
|
||||||
|
refs2 = {"v/A.vue": {"error-msg": 2, "btn-primary": 2}, "v/B.vue": {"error-msg": 1}}
|
||||||
|
assert await sync_repo_consumers(pid, REPO, refs2) == 3
|
||||||
|
edges = await consumers_of(rows.values())
|
||||||
|
assert [(e.path, e.count) for e in edges[rows[("v/B.vue", "error-msg")]]] == [("v/B.vue", 1)]
|
||||||
|
assert [(e.path, e.count) for e in edges[rows[("assets/components.css", "btn-primary")]]] == [("v/A.vue", 2)]
|
||||||
|
|
||||||
|
# The readout side: used_by per css row, the unused-css flag, and the
|
||||||
|
# family's consumers on the write-path check.
|
||||||
|
from scribe.services.shape_ledger import (
|
||||||
|
apply_derive_groups, used_by_map, write_time_derive,
|
||||||
|
)
|
||||||
|
owner = seeded["owner"]
|
||||||
|
live = [r for r in await live_rows(pid) if r.kind == "css"]
|
||||||
|
used = await used_by_map(live)
|
||||||
|
assert used[rows[("v/B.vue", "error-msg")]] == {"count": 1, "paths": ["v/B.vue"]}
|
||||||
|
assert used[rows[("assets/orphan.css", "orphan")]] == {"count": 0, "paths": []}
|
||||||
|
unused, n = await list_project_shapes(owner, pid, flag="unused-css")
|
||||||
|
assert n == 1 and [(r.path, r.symbol) for r in unused] == [("assets/orphan.css", "orphan")]
|
||||||
|
await apply_derive_groups(pid)
|
||||||
|
out = await write_time_derive(pid, "v/New.vue", [("css", "error-msg")])
|
||||||
|
assert out and out[0]["family"]["consumers"] == {"count": 2, "paths": ["v/A.vue", "v/B.vue"]}
|
||||||
|
|
||||||
|
# B's rule vanishes from the tree → its edges go with the pass.
|
||||||
|
await sync_repo_shapes(pid, REPO, [d for d in defs if d[0] != "v/B.vue"], seen_marker="m2")
|
||||||
|
await sync_repo_consumers(pid, REPO, refs2)
|
||||||
|
edges = await consumers_of(rows.values())
|
||||||
|
assert rows[("v/B.vue", "error-msg")] not in edges
|
||||||
|
|
||||||
|
|
||||||
# --- #2793: the divergence readout against real rows -------------------------
|
# --- #2793: the divergence readout against real rows -------------------------
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Cross-family contracts for the `list_*` MCP tools (#2278, shape 4).
|
||||||
|
|
||||||
|
Per-tool tests cover what each list tool does. Nothing covered what the FAMILY
|
||||||
|
owes its callers, which is where the missing-sibling shape hides: a capability
|
||||||
|
added to one member and not its neighbour changes no return value, so no
|
||||||
|
behavioural test can see it. Source inspection can.
|
||||||
|
|
||||||
|
WHAT THIS DELIBERATELY DOES NOT ASSERT. The 19 `list_*` tools are genuinely
|
||||||
|
heterogeneous — 8 take `project_id`, 6 take `limit`, and six take no arguments
|
||||||
|
at all (`list_projects`, `list_trash`, `list_rulebooks`, `list_design_systems`,
|
||||||
|
`list_repo_bindings`, `list_starter_role_groups`). Requiring a common parameter
|
||||||
|
across them would be inventing a convention the API does not have, which the
|
||||||
|
DRY process's over-DRY guard (§5) warns against by name: a wrong abstraction is
|
||||||
|
worse than the duplication. So this file asserts ONE contract, the one that is
|
||||||
|
a real promise rather than a shape coincidence.
|
||||||
|
|
||||||
|
THE CONTRACT: a `limit` without an `offset` is a truncation with no
|
||||||
|
continuation. The caller is told there are 250 results and handed 50, with no
|
||||||
|
way to ask for the rest. Both tools that had this were capped over a service
|
||||||
|
that already accepted an offset — `snippets_svc.list_snippets(offset=0)` was
|
||||||
|
simply not exposed, and `list_processes` passed a hardcoded `offset=0` into
|
||||||
|
`query_knowledge`. The capability existed one layer down in both cases; only
|
||||||
|
the door was missing.
|
||||||
|
|
||||||
|
As in `test_mcp_auth`, the CANDIDATES are derived and the DECISION is explicit.
|
||||||
|
Deriving the exemption too would make the contract follow a naming convention,
|
||||||
|
so any future `list_*` could opt itself out by accident.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
TOOLS_DIR = pathlib.Path(__file__).resolve().parents[1] / "src" / "scribe" / "mcp" / "tools"
|
||||||
|
|
||||||
|
# `limit` here caps a RANKED top-N, not a page into a corpus, so there is no
|
||||||
|
# "rest" to ask for — the 51st most-used tag is not what the caller wanted and
|
||||||
|
# an offset into that ordering answers no question. Anything added here needs a
|
||||||
|
# reason of that kind, not "it isn't paged yet".
|
||||||
|
_DELIBERATELY_UNPAGED = {
|
||||||
|
"list_tags", # most-used tags by count, over a bounded vocabulary
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _list_tools() -> dict[str, set[str]]:
|
||||||
|
"""{tool name: parameter names} for every `list_*` in the tools package."""
|
||||||
|
out: dict[str, set[str]] = {}
|
||||||
|
for path in sorted(TOOLS_DIR.glob("*.py")):
|
||||||
|
if path.name == "__init__.py":
|
||||||
|
continue
|
||||||
|
for node in ast.parse(path.read_text()).body:
|
||||||
|
if (
|
||||||
|
isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef))
|
||||||
|
and node.name.startswith("list_")
|
||||||
|
):
|
||||||
|
out[node.name] = {
|
||||||
|
a.arg for a in node.args.args
|
||||||
|
} | {a.arg for a in node.args.kwonlyargs}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_tools_package_is_where_we_think_it_is():
|
||||||
|
"""If this fails the sweep below is silently checking nothing."""
|
||||||
|
tools = _list_tools()
|
||||||
|
assert len(tools) >= 15, f"found only {len(tools)} list tools — did the package move?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_capped_list_tool_can_be_paged():
|
||||||
|
"""A `limit` promises a cap; without an `offset` it also imposes a ceiling."""
|
||||||
|
tools = _list_tools()
|
||||||
|
capped = {name for name, args in tools.items() if "limit" in args}
|
||||||
|
assert capped, "no list tool takes a limit — the sweep is not finding signatures"
|
||||||
|
|
||||||
|
unpageable = sorted(
|
||||||
|
name for name in capped
|
||||||
|
if "offset" not in tools[name] and name not in _DELIBERATELY_UNPAGED
|
||||||
|
)
|
||||||
|
assert not unpageable, (
|
||||||
|
f"these list tools cap their results with no way to page past the cap: "
|
||||||
|
f"{unpageable}. Each hands the caller a `total` it cannot reach. Add an "
|
||||||
|
f"`offset` (check the service first — it usually already takes one), or "
|
||||||
|
f"add the tool to _DELIBERATELY_UNPAGED with a reason saying why there "
|
||||||
|
f"is no 'rest' to ask for."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_unpaged_exemptions_still_exist():
|
||||||
|
"""A stale exemption is an exemption for nothing, and it hides the next
|
||||||
|
tool that inherits the name. Same reverse check `test_mcp_auth` runs on
|
||||||
|
its allow-lists."""
|
||||||
|
tools = _list_tools()
|
||||||
|
missing = sorted(_DELIBERATELY_UNPAGED - set(tools))
|
||||||
|
assert not missing, (
|
||||||
|
f"_DELIBERATELY_UNPAGED names tools that no longer exist: {missing}. "
|
||||||
|
f"Renamed or deleted — drop them from the set."
|
||||||
|
)
|
||||||
|
still_capped = sorted(
|
||||||
|
name for name in _DELIBERATELY_UNPAGED
|
||||||
|
if name in tools and "limit" not in tools[name]
|
||||||
|
)
|
||||||
|
assert not still_capped, (
|
||||||
|
f"these are exempted from paging but no longer take a `limit` at all, "
|
||||||
|
f"so the exemption is moot: {still_capped}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_offset_never_appears_without_limit():
|
||||||
|
"""The inverse, and it is a real bug rather than a style point: an offset
|
||||||
|
with no cap pages through an unbounded result set, so page 2 of an
|
||||||
|
ever-growing list silently returns everything after the skip."""
|
||||||
|
tools = _list_tools()
|
||||||
|
bad = sorted(
|
||||||
|
name for name, args in tools.items()
|
||||||
|
if "offset" in args and "limit" not in args
|
||||||
|
)
|
||||||
|
assert not bad, f"these take an offset but no limit: {bad}"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user