"""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": "", "decided_by": | null, "via": "mcp" | "ui" | "legacy", "choices": { "design_system_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: }. 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": , "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=, seed_systems=)" ), }