CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 26s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Failing after 45s
CI & Build / Build & push image (push) Skipped
- MCP create_project(..., exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id (0 unstated / -1 none / n), seed_systems): any inception arg → inception.decide(via="mcp") after the create; none → undecided with an inception_hint. New decide_project_inception(project_id, …) records or re-records; nothing given = an inherit-all decision, stated. - enter_project carries `inception` ONLY for the caller's own, undecided project: inception_ask() = the project's current defaults + what to ask the operator once + the exact call (the #2683 ask shape). Absent otherwise. - REST: POST /api/projects accepts `inception` (validated before the create); POST /api/projects/<id>/inception decides/re-decides; GET …/inception/defaults is the card's payload; GET project already carries inception via to_dict. - _INSTRUCTIONS: ORIENT names the ask; START a project names the questions — never create a project bare by default (product behaviour, P#119). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
273 lines
12 KiB
Python
273 lines
12 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": {
|
|
"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>)"
|
|
),
|
|
}
|
|
|