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