feat(inception): always-on exclusions reach every rule surface — list_always_on_rules(project_id), get_applicable_rules, rules_payload.excluded_always_on, session context, exclude/include tools (#2880, milestone 297 step 2)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Failing after 1m12s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / integration (push) Successful in 1m3s
CI & Build / Python tests (push) Failing after 1m12s
CI & Build / Build & push image (push) Skipped
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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", []),
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user