CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / Python tests (push) Failing after 1m3s
CI & Build / Build & push image (push) Skipped
A rule's home is its scope now: a rule in a rulebook topic is global, a rule on a project applies to that project, and retrieval reads that directly (#4074). A subscription had stopped changing anything a session received; a suppression muted rules from a subscription. Operator, 2026-09-15: "we have global and project scoped rules, we don't need the subscriptions now." What goes, whole (rule 22): - Migration 0101 drops project_rulebook_subscriptions, project_rule_suppressions and project_topic_suppressions, and strips subscribe_rulebooks (and 394's leftover exclude_always_on_rulebooks) from stored inception choices. - Service, MCP and REST: subscribe/unsubscribe and the four suppress/unsuppress operations. The Subscribers checklist, the subscribe chips, the skip buttons and the Suppressed section in the rules UI. - Inception asks two questions (design system, seed Systems). create_project and decide_project_inception lose subscribe_rulebooks. - Backup v15 stops exporting the three sections; older archives still restore, the keys simply unread. Trash no longer hard-deletes suppression rows. What changes meaning: - get_applicable_rules is a project's LISTING: its own rules, plus the global rules tagged to an area it works in. Untagged global rules apply everywhere and arrive by retrieval, so they are not listed. A co_surfaces partner on a different project is not dragged in. - list_rules(project_id) lists that project's own rules. - rules_payload drops subscribed_rulebooks and suppressed_*; the handshake's brief form is project_rules alone. - using-scribe's "Where a new rule goes" and inception sections, tool docstrings and docs say global vs project. Plugin 2026.09.15.1620. Milestone 414 step 2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
203 lines
8.2 KiB
Python
203 lines
8.2 KiB
Python
"""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": {
|
|
"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.
|
|
|
|
Rules are not a choice any more. ``exclude_always_on_rulebooks`` went with the
|
|
always-on tier (milestone 394), and ``subscribe_rulebooks`` went with
|
|
subscriptions (milestone 414): a rule in a rulebook is global and applies to
|
|
every project, and a project's own rules are written on it directly. Migration
|
|
0101 strips both keys from stored records.
|
|
|
|
The shape and its validator are pure; ``decide`` composes the existing
|
|
services — set_project_design_system and 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 scribe.models import async_session
|
|
from scribe.models.project import Project
|
|
|
|
INCEPTION_VIAS = ("mcp", "ui", "legacy")
|
|
CHOICE_KEYS = ("design_system_id", "seed_systems")
|
|
|
|
|
|
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 two keys, each optional: ``design_system_id`` an int or None,
|
|
``seed_systems`` a bool. Unknown keys are an error — a typo, or a choice
|
|
the product no longer offers, must not become a silently ignored one."""
|
|
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)})"
|
|
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:
|
|
"""Both keys, always present, in canonical form — what gets stored
|
|
and what the UI/agent reads back. Call after validate_inception."""
|
|
choices = choices or {}
|
|
return {
|
|
"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.
|
|
|
|
{design_system_id, design_systems: [{id,title}], systems: <count>}.
|
|
Instance-agnostic: an install with no design systems shows an empty list,
|
|
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 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")
|
|
designs = await design_systems_svc.list_design_systems(user_id)
|
|
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
|
|
return {
|
|
"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
|
|
|
|
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 (readable)
|
|
first; then, each idempotent: 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
|
|
replaces the design system and re-seeds nothing a project already has.
|
|
|
|
Returns {"inception": <record>, "effects": {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 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)
|
|
|
|
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": {
|
|
"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 {}
|
|
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 "
|
|
"inherits. 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 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}, "
|
|
"design_system_id=<id | -1 for none>, seed_systems=<true|false>)"
|
|
),
|
|
}
|
|
|