From e9b8f525c86a93ded1ebf45dd095319d7d100301 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 21:57:02 -0400 Subject: [PATCH 1/8] =?UTF-8?q?feat(inception):=20projects.inception=20rec?= =?UTF-8?q?ord=20+=20project=5Frulebook=5Fexclusions=20=E2=80=94=20migrati?= =?UTF-8?q?on=200085=20with=20legacy=20backfill;=20backup=20v10=20(#2879,?= =?UTF-8?q?=20milestone=20297=20step=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project's inheritance becomes a decision, not a default (milestone 297). - projects.inception (JSONB, NULL = undecided): {decided_at, decided_by, via mcp|ui|legacy, choices {exclude_always_on_rulebooks, subscribe_rulebooks, design_system_id, seed_systems}}; on to_dict. - project_rulebook_exclusions: a project's opt-out of a whole always-on rulebook — the sibling of the rule/topic suppressions, CASCADE both ways. - services/inception.py (first cut): the vocabulary, validate_inception (pure, all-or-nothing), normalize_choices, is_decided. Effects come in step 3. - Migration 0085 backfills every existing project 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 this ships. - Backup v10: rulebook_exclusions section; project rows carry inception and design_system_id, restored in a post-pass once rulebooks/design systems are mapped (design_system_id was not restored before — fixed in passing). Co-Authored-By: Claude Fable 5 --- alembic/versions/0085_project_inception.py | 74 +++++++++++++++++++ src/scribe/models/project.py | 10 +++ src/scribe/models/rulebook.py | 13 ++++ src/scribe/services/backup.py | 67 +++++++++++++++-- src/scribe/services/inception.py | 84 ++++++++++++++++++++++ tests/test_inception.py | 57 +++++++++++++++ tests/test_services_backup.py | 4 +- 7 files changed, 303 insertions(+), 6 deletions(-) create mode 100644 alembic/versions/0085_project_inception.py create mode 100644 src/scribe/services/inception.py create mode 100644 tests/test_inception.py diff --git a/alembic/versions/0085_project_inception.py b/alembic/versions/0085_project_inception.py new file mode 100644 index 0000000..40e776b --- /dev/null +++ b/alembic/versions/0085_project_inception.py @@ -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") diff --git a/src/scribe/models/project.py b/src/scribe/models/project.py index 3297106..f9e4869 100644 --- a/src/scribe/models/project.py +++ b/src/scribe/models/project.py @@ -1,5 +1,6 @@ import enum from sqlalchemy import BigInteger, ForeignKey, Integer, Text +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base 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"), 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: return { @@ -48,6 +57,7 @@ class Project(Base, TimestampMixin, SoftDeleteMixin): "color": self.color, "design_system_id": self.design_system_id, "forge_connection_id": self.forge_connection_id, + "inception": self.inception, "created_at": iso(self.created_at), "updated_at": iso(self.updated_at), } diff --git a/src/scribe/models/rulebook.py b/src/scribe/models/rulebook.py index 7a1cdf5..8217999 100644 --- a/src/scribe/models/rulebook.py +++ b/src/scribe/models/rulebook.py @@ -129,6 +129,19 @@ project_rule_suppressions = Table( 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", Base.metadata, diff --git a/src/scribe/services/backup.py b/src/scribe/services/backup.py index d01c0a8..2f8cbff 100644 --- a/src/scribe/services/backup.py +++ b/src/scribe/services/backup.py @@ -19,6 +19,7 @@ from scribe.models.rulebook import ( Rulebook, RulebookTopic, project_rule_suppressions, + project_rulebook_exclusions, project_rulebook_subscriptions, project_topic_suppressions, ) @@ -45,8 +46,10 @@ logger = logging.getLogger(__name__) # v9 (2026-08) added code_shape_uses — the ledger's consumption edges (#2870): # judgment-grade edges (agent/audit/import) are operator records; mechanical # 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. -BACKUP_VERSION = 9 +BACKUP_VERSION = 10 # 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 @@ -60,7 +63,7 @@ _BACKED_UP = [ "users", "projects", "milestones", "notes", "task_logs", "note_drafts", "note_versions", "settings", "rulebooks", "rulebook_topics", "rules", "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. "systems", "record_systems", "design_systems", "design_tokens", "note_usage_events", "repo_bindings", "note_supersessions", @@ -112,6 +115,10 @@ def _topic_suppression_rows(rows) -> list[dict]: 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 # same reason: CI has no database, so a serialiser that is a plain function is # one that can actually be tested. @@ -219,6 +226,8 @@ def _project_rows(rows) -> list[dict]: "id": p.id, "user_id": p.user_id, "title": p.title, "description": p.description, "goal": p.goal, "status": p.status, "color": p.color, + "design_system_id": p.design_system_id, + "inception": p.inception, "created_at": p.created_at.isoformat(), "updated_at": p.updated_at.isoformat(), } @@ -383,6 +392,9 @@ async def export_full_backup() -> dict: topic_suppressions = (await session.execute( select(project_topic_suppressions) )).all() + rulebook_exclusions = (await session.execute( + select(project_rulebook_exclusions) + )).all() return { "version": BACKUP_VERSION, @@ -407,6 +419,7 @@ async def export_full_backup() -> dict: "rulebook_subscriptions": _subscription_rows(subscriptions), "rule_suppressions": _rule_suppression_rows(rule_suppressions), "topic_suppressions": _topic_suppression_rows(topic_suppressions), + "rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions), "systems": _system_rows(systems), "record_systems": _record_system_rows(record_systems), "design_systems": _design_system_rows(design_systems), @@ -532,8 +545,13 @@ async def export_user_backup(user_id: int) -> dict: project_topic_suppressions.c.project_id.in_(project_ids) ) )).all() + rulebook_exclusions = (await session.execute( + select(project_rulebook_exclusions).where( + project_rulebook_exclusions.c.project_id.in_(project_ids) + ) + )).all() else: - subscriptions = rule_suppressions = topic_suppressions = [] + subscriptions = rule_suppressions = topic_suppressions = rulebook_exclusions = [] return { "version": BACKUP_VERSION, @@ -560,6 +578,7 @@ async def export_user_backup(user_id: int) -> dict: "rulebook_subscriptions": _subscription_rows(subscriptions), "rule_suppressions": _rule_suppression_rows(rule_suppressions), "topic_suppressions": _topic_suppression_rows(topic_suppressions), + "rulebook_exclusions": _rulebook_exclusion_rows(rulebook_exclusions), "systems": _system_rows(systems), "record_systems": _record_system_rows(record_systems), "design_systems": _design_system_rows(design_systems), @@ -670,7 +689,7 @@ async def _restore_v2(data: dict) -> dict: "task_logs": 0, "note_drafts": 0, "note_versions": 0, "settings": 0, "rulebooks": 0, "rulebook_topics": 0, "rules": 0, "rulebook_subscriptions": 0, "rule_suppressions": 0, - "topic_suppressions": 0, + "topic_suppressions": 0, "rulebook_exclusions": 0, "systems": 0, "record_systems": 0, "design_systems": 0, "design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0, "note_supersessions": 0, "code_shapes": 0, "code_shape_events": 0, @@ -933,6 +952,17 @@ async def _restore_v2(data: dict) -> dict: )) 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 # payload restores without them rather than failing on an absent key. @@ -1137,6 +1167,35 @@ async def _restore_v2(data: dict) -> dict: )) 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() logger.info("Restored v2/v3 backup: %s", stats) diff --git a/src/scribe/services/inception.py b/src/scribe/services/inception.py new file mode 100644 index 0000000..2e949c4 --- /dev/null +++ b/src/scribe/services/inception.py @@ -0,0 +1,84 @@ +"""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": { + "exclude_always_on_rulebooks": [rulebook ids], + "subscribe_rulebooks": [rulebook ids], + "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. + +Step 1 (this module's first cut) holds the shape and its validator; the +effects (decide / current_defaults) arrive in step 3 and compose the +existing services — subscriptions, exclusions, set_project_design_system, +the Systems starter mint — and write the record LAST. +""" +from __future__ import annotations + +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)) diff --git a/tests/test_inception.py b/tests/test_inception.py new file mode 100644 index 0000000..e10e21e --- /dev/null +++ b/tests/test_inception.py @@ -0,0 +1,57 @@ +"""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} diff --git a/tests/test_services_backup.py b/tests/test_services_backup.py index 05b062e..3eaac1c 100644 --- a/tests/test_services_backup.py +++ b/tests/test_services_backup.py @@ -18,7 +18,7 @@ def test_backup_version_is_v8(): point of the test — a payload section added without moving the version produces backups that are structurally different and indistinguishable by inspection.""" - assert backup.BACKUP_VERSION == 9 + assert backup.BACKUP_VERSION == 10 def test_not_included_lists_the_known_gaps(): @@ -116,7 +116,7 @@ async def test_export_full_backup_contains_every_declared_section(): "systems", "record_systems", "design_systems", "design_tokens", "note_usage_events", "repo_bindings", "note_supersessions", "code_shapes", "code_shape_events", - "code_shape_uses"): + "code_shape_uses", "rulebook_exclusions"): assert key in out, f"missing export section: {key}" assert out[key] == [] -- 2.54.0 From ff5f6438c4a4d7cc0353a0e442e2178725188427 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 22:00:50 -0400 Subject: [PATCH 2/8] =?UTF-8?q?feat(inception):=20always-on=20exclusions?= =?UTF-8?q?=20reach=20every=20rule=20surface=20=E2=80=94=20list=5Falways?= =?UTF-8?q?=5Fon=5Frules(project=5Fid),=20get=5Fapplicable=5Frules,=20rule?= =?UTF-8?q?s=5Fpayload.excluded=5Falways=5Fon,=20session=20context,=20excl?= =?UTF-8?q?ude/include=20tools=20(#2880,=20milestone=20297=20step=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project that opted out of an always-on rulebook at inception must not see it anywhere: list_always_on_rules(project_id=) and the subscription-derived set skip it (an exclusion is total), get_applicable_rules / rules_payload carry `excluded_always_on` as the seventh key so the departure is visible wherever the rules are, and the SessionStart block built for a bound project names it ("Excluded for this project by its inception decision …"). MCP: list_always_on_rules takes project_id; exclude_always_on_rulebook / include_always_on_rulebook mirror suppress/unsuppress (owner-only; the rulebook must be always_on — subscribed rulebooks are left by unsubscribing). Co-Authored-By: Claude Fable 5 --- src/scribe/mcp/tools/rulebooks.py | 40 +++++++++- src/scribe/services/plugin_context.py | 15 +++- src/scribe/services/rulebooks.py | 111 ++++++++++++++++++++++++-- tests/test_inception_rules.py | 59 ++++++++++++++ tests/test_mcp_tool_rulebooks.py | 3 + 5 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 tests/test_inception_rules.py diff --git a/src/scribe/mcp/tools/rulebooks.py b/src/scribe/mcp/tools/rulebooks.py index 15c89b7..b496648 100644 --- a/src/scribe/mcp/tools/rulebooks.py +++ b/src/scribe/mcp/tools/rulebooks.py @@ -222,16 +222,22 @@ async def list_rules( 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. 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. Pair with get_project(id).applicable_rules when working on a specific 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() - 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)} @@ -407,6 +413,35 @@ async def unsubscribe_project_from_rulebook( # ── 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( project_id: int, rule_id: int, ) -> dict: @@ -470,5 +505,6 @@ def register(mcp) -> None: subscribe_project_to_rulebook, unsubscribe_project_from_rulebook, suppress_rule_for_project, unsuppress_rule_for_project, suppress_topic_for_project, unsuppress_topic_for_project, + exclude_always_on_rulebook, include_always_on_rulebook, ): mcp.tool(name=fn.__name__)(fn) diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index 5629e02..15d8e2e 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -1097,7 +1097,14 @@ async def build_session_context( at _MAX_CHARS with an explicit truncation note so the hook can pass it 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}) lines: list[str] = [ @@ -1119,6 +1126,12 @@ async def build_session_context( heading = topic_map.get(r.topic_id, "ungrouped") if r.topic_id else "ungrouped" lines.append(f"### {heading}") 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 if project_id: diff --git a/src/scribe/services/rulebooks.py b/src/scribe/services/rulebooks.py index 0517aad..787b935 100644 --- a/src/scribe/services/rulebooks.py +++ b/src/scribe/services/rulebooks.py @@ -394,15 +394,57 @@ async def list_rules( 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. 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 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: - result = await session.execute( + q = ( select(Rule) .join(RulebookTopic, Rule.topic_id == RulebookTopic.id) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) @@ -413,10 +455,13 @@ async def list_always_on_rules(user_id: int, limit: int = 100) -> list[Rule]: RulebookTopic.deleted_at.is_(None), Rulebook.deleted_at.is_(None), ) - .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, - ) - .limit(limit) + ).limit(limit) ) return list(result.scalars().all()) @@ -489,6 +534,7 @@ async def delete_rule(rule_id: int, user_id: int) -> None: # ── Subscriptions + get_applicable_rules ─────────────────────────────── from sqlalchemy import insert, delete as sql_delete +from sqlalchemy.exc import IntegrityError async def subscribe_project( @@ -568,6 +614,51 @@ async def unsuppress_rule_for_project( 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( project_id: int, topic_id: int, user_id: int, ) -> None: @@ -731,6 +822,9 @@ async def get_applicable_rules( Rule.deleted_at.is_(None), RulebookTopic.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( Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title, @@ -778,6 +872,7 @@ async def get_applicable_rules( "suppressed_topics": suppressed_topics, "truncated": truncated, "subscribed_rulebooks": subscribed_rulebooks, + "excluded_always_on": await excluded_always_on_rulebooks(user_id, project_id), } @@ -786,9 +881,12 @@ def rules_payload(applicable: dict) -> dict: Every surface that hands rules to an agent (enter_project, get_project, 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` → `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 { "applicable_rules": applicable["rules"], @@ -797,4 +895,5 @@ def rules_payload(applicable: dict) -> dict: "project_rules": applicable.get("project_rules", []), "suppressed_rules": applicable.get("suppressed_rules", []), "suppressed_topics": applicable.get("suppressed_topics", []), + "excluded_always_on": applicable.get("excluded_always_on", []), } diff --git a/tests/test_inception_rules.py b/tests/test_inception_rules.py new file mode 100644 index 0000000..effa931 --- /dev/null +++ b/tests/test_inception_rules.py @@ -0,0 +1,59 @@ +"""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"] diff --git a/tests/test_mcp_tool_rulebooks.py b/tests/test_mcp_tool_rulebooks.py index 004c7d2..199309e 100644 --- a/tests/test_mcp_tool_rulebooks.py +++ b/tests/test_mcp_tool_rulebooks.py @@ -175,6 +175,9 @@ def test_register_attaches_all_sixteen_tools(): assert "create_rule" in mcp.names assert "subscribe_project_to_rulebook" in mcp.names assert "list_always_on_rules" in mcp.names + # milestone 297: a project's opt-out of a whole always-on rulebook + assert "exclude_always_on_rulebook" in mcp.names + assert "include_always_on_rulebook" in mcp.names assert "create_project_rule" in mcp.names assert "suppress_rule_for_project" in mcp.names assert "unsuppress_rule_for_project" in mcp.names -- 2.54.0 From 34734bf84a918fa4adc9b070b0eb42711baf2be0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 22:03:02 -0400 Subject: [PATCH 3/8] feat(inception): services/inception.decide() + current_defaults(); the standard Systems vocabulary moves to the service and seeds at inception (#2881, milestone 297 step 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inception.decide(user, project, choices=, via=): owner-only; validates the choices (pure) and every target (owned rulebook / always-on for an exclusion / readable design system) BEFORE any effect; then, each idempotent: exclude always-on rulebooks, subscribe rulebooks, point the design system (None = explicitly none), seed the standard Systems if asked and the project has none; writes projects.inception LAST. Re-deciding is additive for exclusions/subscriptions, replaces the design system, never re-seeds. - inception.current_defaults(): what binds if nobody decides — the ask's payload (always-on / other rulebooks, standing exclusions + subscriptions, design system + the choices, Systems count). - services/systems.STANDARD_SYSTEMS (name + generic charter) + seed_standard_systems(); mcp/tools/systems names the same list in the bootstrap ask — one vocabulary. - Integration tests: effects land and the record says why; bad targets apply nothing; outsiders cannot decide. Co-Authored-By: Claude Fable 5 --- src/scribe/mcp/tools/systems.py | 8 +- src/scribe/services/inception.py | 163 +++++++++++++++++++++++++++- src/scribe/services/systems.py | 35 ++++++ tests/test_inception.py | 8 ++ tests/test_integration_inception.py | 111 +++++++++++++++++++ 5 files changed, 317 insertions(+), 8 deletions(-) create mode 100644 tests/test_integration_inception.py diff --git a/src/scribe/mcp/tools/systems.py b/src/scribe/mcp/tools/systems.py index 2ccf049..2431d67 100644 --- a/src/scribe/mcp/tools/systems.py +++ b/src/scribe/mcp/tools/systems.py @@ -30,10 +30,10 @@ _BOOTSTRAP_TITLES = 6 # design (rule #115): archetypes any codebase could have, never one # install's subsystems. Mint freely beyond the list; the duplicate gate # guards sprawl. -_STANDARD_SYSTEMS = ( - "CI & Release", "Auth & Access", "Data Model & Storage", "API Surface", - "UI & Design", "Import & Export", "Background Jobs", "Observability", -) +# The standard vocabulary lives with the service (services/systems. +# STANDARD_SYSTEMS) since milestone 297 — the inception seed mints it and this +# ask names it, one list for both. +_STANDARD_SYSTEMS = tuple(name for name, _charter in systems_svc.STANDARD_SYSTEMS) async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None: diff --git a/src/scribe/services/inception.py b/src/scribe/services/inception.py index 2e949c4..3a5e0af 100644 --- a/src/scribe/services/inception.py +++ b/src/scribe/services/inception.py @@ -18,13 +18,24 @@ 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. -Step 1 (this module's first cut) holds the shape and its validator; the -effects (decide / current_defaults) arrive in step 3 and compose the -existing services — subscriptions, exclusions, set_project_design_system, -the Systems starter mint — and write the record LAST. +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") @@ -82,3 +93,147 @@ def normalize_choices(choices: dict | None) -> dict: 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: }. + 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": , "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], + }, + } + diff --git a/src/scribe/services/systems.py b/src/scribe/services/systems.py index 83f5785..e72b2b6 100644 --- a/src/scribe/services/systems.py +++ b/src/scribe/services/systems.py @@ -18,6 +18,41 @@ from scribe.services import access logger = logging.getLogger(__name__) +# The standard cross-project vocabulary (#2798): names that mean the same +# thing in every project, so a starter set reads the same everywhere. The +# bootstrap ask (mcp/tools/systems) names them; the inception seed +# (services/inception, milestone 297) mints them. Charters are deliberately +# generic — a project refines them as its own records accrue. +STANDARD_SYSTEMS: tuple[tuple[str, str], ...] = ( + ("CI & Release", "How the project is verified and shipped: pipelines, runners, image/artifact builds, release tagging and rollback."), + ("Auth & Access", "Who may do what: identity, sessions/tokens, permissions and the scoping of every read and write to the right users."), + ("Data Model & Storage", "What is stored and how it is shaped: the schema, migrations, serialisation and the services that own a table's lifecycle."), + ("API Surface", "The doors into the capability: HTTP routes, tool/RPC surfaces, request parsing, error envelopes and their contracts."), + ("UI & Design", "What people see and touch: views, components, client state, and the design tokens/recipes they are built from."), + ("Import & Export", "Data crossing the boundary: backups, exports, imports, sync with other systems, file formats."), + ("Background Jobs", "Work that runs without a request: schedulers, queues, periodic ticks, retention and maintenance."), + ("Observability", "How the system reports on itself: logging, metrics, audit trails, health and diagnostics."), +) + + +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.""" + if await list_systems(user_id, project_id, include_archived=True): + return [] + out: list[System] = [] + for index, (name, charter) in enumerate(STANDARD_SYSTEMS): + system = await create_system( + user_id, project_id, name, description=charter, order_index=index, + ) + if system is None: + break + out.append(system) + return out + + async def create_system( user_id: int, project_id: int, diff --git a/tests/test_inception.py b/tests/test_inception.py index e10e21e..75e44e7 100644 --- a/tests/test_inception.py +++ b/tests/test_inception.py @@ -55,3 +55,11 @@ def test_normalize_choices_is_canonical_and_complete(): "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_is_one_list_for_ask_and_seed(): + from scribe.mcp.tools.systems import _STANDARD_SYSTEMS + from scribe.services.systems import STANDARD_SYSTEMS + assert _STANDARD_SYSTEMS == tuple(n for n, _ in STANDARD_SYSTEMS) + assert len(STANDARD_SYSTEMS) == 8 and all(charter for _, charter in STANDARD_SYSTEMS) + diff --git a/tests/test_integration_inception.py b/tests/test_integration_inception.py new file mode 100644 index 0000000..51ecedc --- /dev/null +++ b/tests/test_integration_inception.py @@ -0,0 +1,111 @@ +"""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 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"]] + assert len(out["effects"]["systems_seeded"]) == len(systems_svc.STANDARD_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(systems_svc.STANDARD_SYSTEMS) + 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={}) -- 2.54.0 From 227aef3dbf9286789c481e67857ef6022a416462 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 22:04:04 -0400 Subject: [PATCH 4/8] test: mocked-session rule tests stub the always-on exclusions lookup; tool count 24 (#2880) Co-Authored-By: Claude Fable 5 --- tests/test_mcp_tool_rulebooks.py | 2 +- tests/test_services_plugin_context.py | 9 +++++++++ tests/test_services_rulebooks.py | 9 +++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/test_mcp_tool_rulebooks.py b/tests/test_mcp_tool_rulebooks.py index 199309e..526a2aa 100644 --- a/tests/test_mcp_tool_rulebooks.py +++ b/tests/test_mcp_tool_rulebooks.py @@ -169,7 +169,7 @@ def test_register_attaches_all_sixteen_tools(): mcp = FakeMCP() register(mcp) - assert len(mcp.names) == 22 + assert len(mcp.names) == 24 # +exclude/include_always_on_rulebook (milestone 297) # spot-check a few names assert "list_rulebooks" in mcp.names assert "create_rule" in mcp.names diff --git a/tests/test_services_plugin_context.py b/tests/test_services_plugin_context.py index f67aa0e..d0c9026 100644 --- a/tests/test_services_plugin_context.py +++ b/tests/test_services_plugin_context.py @@ -4,6 +4,15 @@ import pytest from tests.helpers import fake_note +@pytest.fixture(autouse=True) +def _no_exclusions(): + """build_session_context asks for the bound project's always-on + exclusions (milestone 297); these tests script the rules only.""" + with patch("scribe.services.plugin_context.rulebooks_svc.excluded_always_on_rulebooks", + AsyncMock(return_value=[])): + yield + + pytestmark = pytest.mark.usefixtures("_no_supersession") diff --git a/tests/test_services_rulebooks.py b/tests/test_services_rulebooks.py index c5871f6..2be99cb 100644 --- a/tests/test_services_rulebooks.py +++ b/tests/test_services_rulebooks.py @@ -8,6 +8,15 @@ import pytest from tests.helpers import fake_rule, fake_rulebook, fake_topic, make_mock_session +@pytest.fixture(autouse=True) +def _no_exclusions(): + """get_applicable_rules asks for the project's always-on exclusions + (milestone 297) through its own session; these mocked-session tests + script the rule queries only, so the exclusions lookup is stubbed empty.""" + with patch("scribe.services.rulebooks.excluded_always_on_rulebooks", AsyncMock(return_value=[])): + yield + + @pytest.mark.asyncio async def test_create_rulebook_stores_to_db(): mock_session = make_mock_session() -- 2.54.0 From c7a58bb610388e92023a7a10e44667ec26269e43 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 22:06:19 -0400 Subject: [PATCH 5/8] =?UTF-8?q?feat(inception):=20the=20doors=20=E2=80=94?= =?UTF-8?q?=20create=5Fproject/decide=5Fproject=5Finception=20take=20the?= =?UTF-8?q?=20decision,=20enter=5Fproject=20asks=20until=20decided,=20REST?= =?UTF-8?q?=20inception=20endpoints,=20=5FINSTRUCTIONS=20(#2882,=20milesto?= =?UTF-8?q?ne=20297=20step=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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//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 --- src/scribe/mcp/server.py | 7 ++- src/scribe/mcp/tools/projects.py | 105 ++++++++++++++++++++++++++++++- src/scribe/routes/projects.py | 49 ++++++++++++++- src/scribe/services/inception.py | 33 ++++++++++ tests/test_mcp_tool_projects.py | 78 +++++++++++++++++++++++ 5 files changed, 267 insertions(+), 5 deletions(-) diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index 149d12c..fee91c4 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -37,7 +37,12 @@ in local files (CLAUDE.md, auto-memory); Scribe holds the single copy. Hierarchy: Project -> Milestone -> Task/Note. The map, by purpose: - ORIENT: enter_project(id) at session start — rules, open tasks, recent - notes, Systems and design system in one call. + notes, Systems and design system in one call. If it returns `inception`, + the project's inheritance was never decided: raise that ask once, then + decide_project_inception. +- START a project: create_project takes what it inherits — always-on + rulebooks to exclude, rulebooks to subscribe, design system, seed + Systems. Ask the operator first; never create a project bare by default. - 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; keep status honest — in_progress on start, done on finish. diff --git a/src/scribe/mcp/tools/projects.py b/src/scribe/mcp/tools/projects.py index f74b3ed..b2620d6 100644 --- a/src/scribe/mcp/tools/projects.py +++ b/src/scribe/mcp/tools/projects.py @@ -20,6 +20,7 @@ from scribe.mcp._context import current_user_id from scribe.mcp.tools import systems as systems_tools from scribe.services import coverage as coverage_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 notes as notes_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 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 at all — act on it before starting other work: create_system a 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 ) + # 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 # the pulls it caused floated unattributed and the surfaced:pulled ratio # 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. if systems_bootstrap: out["systems_bootstrap"] = systems_bootstrap + if inception_ask: + out["inception"] = inception_ask return out @@ -238,14 +255,43 @@ async def get_project(project_id: int) -> dict: 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( title: str, description: str = "", goal: str = "", status: str = "active", 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: - """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: title: Project name (required). @@ -253,6 +299,14 @@ async def create_project( goal: The desired outcome or definition of done for the project. status: one of active (default), paused, completed, archived. 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() project = await projects_svc.create_project( @@ -263,7 +317,52 @@ async def create_project( status=status, 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( @@ -320,6 +419,6 @@ def register(mcp) -> None: get_project, create_project, update_project, - delete_project, + delete_project, decide_project_inception, ): mcp.tool(name=fn.__name__)(fn) diff --git a/src/scribe/routes/projects.py b/src/scribe/routes/projects.py index 2a45fe4..fd9b954 100644 --- a/src/scribe/routes/projects.py +++ b/src/scribe/routes/projects.py @@ -5,6 +5,7 @@ from quart import Blueprint, g, jsonify, request from scribe.auth import login_required, get_current_user_id 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.notes import list_notes from scribe.services.projects import ( @@ -66,6 +67,15 @@ async def create_project_route(): status = data.get("status", "active") if status not in ("active", "paused", "completed", "archived"): 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( uid, title=data["title"], @@ -74,7 +84,44 @@ async def create_project_route(): color=data.get("color"), 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("//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("//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("/", methods=["GET"]) diff --git a/src/scribe/services/inception.py b/src/scribe/services/inception.py index 3a5e0af..65d18ee 100644 --- a/src/scribe/services/inception.py +++ b/src/scribe/services/inception.py @@ -237,3 +237,36 @@ async def decide( }, } + +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=, seed_systems=)" + ), + } + diff --git a/tests/test_mcp_tool_projects.py b/tests/test_mcp_tool_projects.py index a434ee4..5f7ef98 100644 --- a/tests/test_mcp_tool_projects.py +++ b/tests/test_mcp_tool_projects.py @@ -391,3 +391,81 @@ def test_enter_project_registered_in_register(): register(mcp) assert "enter_project" in mcp.names + + +# --- milestone 297: the inception doors --------------------------------------- + + +@pytest.mark.asyncio +async def test_create_project_without_inception_args_stays_undecided(): + p = fake_project(id=5, title="P", inception=None) + with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \ + patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock()) as decide: + out = await create_project(title="P") + decide.assert_not_awaited() + assert "inception_hint" in out and "inception_effects" not in out + + +@pytest.mark.asyncio +async def test_create_project_with_inception_args_decides_via_mcp(): + p = fake_project(id=5, title="P", inception=None) + decided = {"inception": {"via": "mcp", "choices": {}}, "effects": {"systems_seeded": []}} + with patch("scribe.mcp.tools.projects.projects_svc.create_project", AsyncMock(return_value=p)), \ + patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide: + out = await create_project(title="P", exclude_always_on_rulebooks=[1], design_system_id=-1, seed_systems=True) + kw = decide.await_args.kwargs + assert decide.await_args.args[1] == 5 and kw["via"] == "mcp" + assert kw["choices"] == {"exclude_always_on_rulebooks": [1], "subscribe_rulebooks": [], + "design_system_id": None, "seed_systems": True} + assert out["inception"]["via"] == "mcp" and "inception_effects" in out + + +@pytest.mark.asyncio +async def test_decide_project_inception_tool_records_an_inherit_all_decision_when_given_nothing(): + from scribe.mcp.tools.projects import decide_project_inception + decided = {"inception": {"via": "mcp"}, "effects": {}} + with patch("scribe.mcp.tools.projects.inception_svc.decide", AsyncMock(return_value=decided)) as decide: + out = await decide_project_inception(project_id=5) + assert decide.await_args.kwargs["choices"] == {} + assert out["project_id"] == 5 and out["inception"]["via"] == "mcp" + + +@pytest.mark.asyncio +async def test_enter_project_carries_the_inception_ask_only_for_an_undecided_own_project(): + applicable = {"rules": [], "project_rules": [], "truncated": False, + "subscribed_rulebooks": [], "excluded_always_on": []} + ask = {"defaults": {}, "ask": "decide", "call": "decide_project_inception(...)"} + + async def run(project): + with patch("scribe.mcp.tools.projects.projects_svc.get_project", AsyncMock(return_value=project)), \ + patch("scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", AsyncMock(return_value=applicable)), \ + patch("scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", AsyncMock(return_value=[])), \ + patch("scribe.mcp.tools.projects.notes_svc.list_notes", AsyncMock(side_effect=[([], 0), ([], 0)])), \ + patch("scribe.mcp.tools.projects.systems_svc.list_systems", AsyncMock(return_value=[])), \ + patch("scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask", AsyncMock(return_value=None)), \ + patch("scribe.mcp.tools.projects.inception_svc.inception_ask", AsyncMock(return_value=ask)) as asked: + return await enter_project(project_id=5), asked + + # Own + undecided → the ask rides along. + out, asked = await run(fake_project(id=5, title="P", user_id=7, inception=None)) + assert out["inception"] == ask and asked.await_count == 1 + # Decided → absent, and the ask is not even built. + out, asked = await run(fake_project(id=5, title="P", user_id=7, inception={"via": "legacy"})) + assert "inception" not in out and asked.await_count == 0 + # Someone else's (shared) project, undecided → not this caller's to decide. + out, asked = await run(fake_project(id=5, title="P", user_id=8, inception=None)) + assert "inception" not in out and asked.await_count == 0 + + +def test_inception_routes_and_tool_are_registered(): + from scribe.app import create_app + from scribe.mcp.server import build_mcp_server + rules = {r.rule for r in create_app().url_map.iter_rules()} + assert "/api/projects//inception" in rules + assert "/api/projects//inception/defaults" in rules + mcp = build_mcp_server() + assert mcp._tool_manager.get_tool("decide_project_inception") is not None + tool = mcp._tool_manager.get_tool("create_project") + for name in ("exclude_always_on_rulebooks", "subscribe_rulebooks", "design_system_id", "seed_systems"): + assert name in tool.parameters.get("properties", {}), name + -- 2.54.0 From 00c7badc3ff637d0e48503659173fd778bef8d01 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 21 Aug 2026 22:09:18 -0400 Subject: [PATCH 6/8] =?UTF-8?q?feat(inception):=20UI=20=E2=80=94=20New-pro?= =?UTF-8?q?ject=20modal=20step=202,=20InceptionCard=20on=20the=20project?= =?UTF-8?q?=20page,=20Rules=20tab=20shows=20excluded=20always-on=20ruleboo?= =?UTF-8?q?ks;=20REST=20exclusion=20routes=20(#2883,=20milestone=20297=20s?= =?UTF-8?q?tep=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - components/InceptionCard.vue: the one form, two homes — mode="create" in the New-project modal's second step (emits the choices; the create carries `inception`), mode="decide" on ProjectView for the owner of an undecided project (loads that project's defaults, records the decision). Always-on rulebooks listed checked (uncheck = exclude), others unchecked (check = subscribe), design system select, seed-Systems toggle (disabled once the project has Systems). Tokens only; modal canon (#2855); .btn-* canon. - ProjectView: the card while undecided, one "Inheritance decided via … · …" line after; onDecided refreshes the project. - ProjectRulesTab: "Excluded always-on rulebooks" section with include-back. - api/inception.ts (types, fetchInceptionDefaults, decideInception); api/rulebooks.ts: ApplicableRules.excluded_always_on, exclude/include wrappers; REST POST/DELETE /api/projects//exclusions/rulebooks/. Co-Authored-By: Claude Fable 5 --- frontend/src/api/inception.ts | 42 ++++ frontend/src/api/rulebooks.ts | 13 ++ frontend/src/components/InceptionCard.vue | 189 ++++++++++++++++++ .../src/components/rules/ProjectRulesTab.vue | 35 +++- frontend/src/views/ProjectListView.vue | 36 +++- frontend/src/views/ProjectView.vue | 31 +++ src/scribe/routes/rulebooks.py | 26 +++ tests/test_inception_rules.py | 7 + 8 files changed, 367 insertions(+), 12 deletions(-) create mode 100644 frontend/src/api/inception.ts create mode 100644 frontend/src/components/InceptionCard.vue diff --git a/frontend/src/api/inception.ts b/frontend/src/api/inception.ts new file mode 100644 index 0000000..05773e1 --- /dev/null +++ b/frontend/src/api/inception.ts @@ -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(`/api/projects/${projectId}/inception/defaults`); + +export const decideInception = (projectId: number, choices: InceptionChoices) => + apiPost(`/api/projects/${projectId}/inception`, { choices }); diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index 858d854..1c52447 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -71,6 +71,8 @@ export interface ApplicableRules { }[]; truncated: boolean; 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 ─────────────────────────────────────────────────────── @@ -181,3 +183,14 @@ export async function suppressTopicForProject(projectId: number, topicId: number export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise { return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`); } + +// ── Always-on exclusions (milestone 297) ──────────────────────────────────── + +export async function excludeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise { + await apiPost(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`, {}); +} + +export async function includeAlwaysOnRulebook(projectId: number, rulebookId: number): Promise { + await apiDelete(`/api/projects/${projectId}/exclusions/rulebooks/${rulebookId}`); +} + diff --git a/frontend/src/components/InceptionCard.vue b/frontend/src/components/InceptionCard.vue new file mode 100644 index 0000000..b37699b --- /dev/null +++ b/frontend/src/components/InceptionCard.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/frontend/src/components/rules/ProjectRulesTab.vue b/frontend/src/components/rules/ProjectRulesTab.vue index 4749279..cd989b0 100644 --- a/frontend/src/components/rules/ProjectRulesTab.vue +++ b/frontend/src/components/rules/ProjectRulesTab.vue @@ -2,10 +2,18 @@ import { ref, onMounted, watch } from "vue"; import { useRouter } from "vue-router"; import { - getProjectApplicableRules, subscribeProject, unsubscribeProject, - listRulebooks, getRule, createProjectRule, deleteRule, - suppressRuleForProject, unsuppressRuleForProject, - suppressTopicForProject, unsuppressTopicForProject, + getProjectApplicableRules, + subscribeProject, + unsubscribeProject, + listRulebooks, + getRule, + createProjectRule, + deleteRule, + suppressRuleForProject, + unsuppressRuleForProject, + suppressTopicForProject, + unsuppressTopicForProject, + includeAlwaysOnRulebook, } from "@/api/rulebooks"; import type { ApplicableRules, Rulebook } from "@/api/rulebooks"; @@ -35,6 +43,11 @@ async function subscribe(rulebookId: number) { await load(); } +async function includeBack(rulebookId: number) { + await includeAlwaysOnRulebook(props.projectId, rulebookId); + await load(); +} + async function unsubscribe(rulebookId: number) { if (!confirm("Unsubscribe from this rulebook for this project?")) return; await unsubscribeProject(props.projectId, rulebookId); @@ -172,6 +185,17 @@ watch(() => props.projectId, load); +
+

Excluded always-on rulebooks

+

Opted out at inception — these do not bind this project.

+
+ + {{ rb.title }} + + +
+
+

Project rules

@@ -321,6 +345,9 @@ watch(() => props.projectId, load);