feat(rules): always_on rulebook flag + Scribe-first prompt
Adds rulebooks.always_on (migration 0058) and a new list_always_on_rules MCP tool so a session-start eager pull can fetch standing rules without needing an active-project notion. Updates _INSTRUCTIONS so Claude calls the new tool at session start and codifies engineering rules in Scribe rather than CLAUDE.md / auto-memory. Seeds FabledSword family rulebook to always_on=true on migrate, matching its design role as the cross-project standards rulebook. Frontend: badge in RulebookListPane for always-on rulebooks; toggle in RulebookDetailPane header bound to a new toggleAlwaysOn store action. This is S1+S2 of the rules-consolidation plan (Scribe task #508). S3 (project-scoped rules) and S4 (enter_project handshake) follow. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -179,8 +179,59 @@ def test_register_attaches_all_sixteen_tools():
|
||||
return decorator
|
||||
|
||||
register(FakeMCP())
|
||||
assert len(registered) == 16
|
||||
assert len(registered) == 17
|
||||
# spot-check a few names
|
||||
assert "list_rulebooks" in registered
|
||||
assert "create_rule" in registered
|
||||
assert "subscribe_project_to_rulebook" in registered
|
||||
assert "list_always_on_rules" in registered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_always_on_rules_returns_empty_when_no_always_on_rulebooks():
|
||||
with patch(
|
||||
"fabledassistant.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
|
||||
AsyncMock(return_value=[]),
|
||||
):
|
||||
from fabledassistant.mcp.tools.rulebooks import list_always_on_rules
|
||||
out = await list_always_on_rules()
|
||||
assert out == {"rules": [], "total": 0}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_always_on_rules_projects_each_rule():
|
||||
rules = [_fake_rule(id=100), _fake_rule(id=101)]
|
||||
with patch(
|
||||
"fabledassistant.mcp.tools.rulebooks.rulebooks_svc.list_always_on_rules",
|
||||
AsyncMock(return_value=rules),
|
||||
):
|
||||
from fabledassistant.mcp.tools.rulebooks import list_always_on_rules
|
||||
out = await list_always_on_rules()
|
||||
assert out["total"] == 2
|
||||
assert {r["id"] for r in out["rules"]} == {100, 101}
|
||||
assert all("topic_id" in r for r in out["rules"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rulebook_forwards_always_on_when_set():
|
||||
rb = _fake_rulebook(id=1, title="t")
|
||||
mock = AsyncMock(return_value=rb)
|
||||
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
|
||||
from fabledassistant.mcp.tools.rulebooks import update_rulebook
|
||||
await update_rulebook(rulebook_id=1, always_on=True)
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert kwargs.get("always_on") is True
|
||||
assert "title" not in kwargs
|
||||
assert "description" not in kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_rulebook_omits_always_on_when_none():
|
||||
rb = _fake_rulebook(id=1, title="t")
|
||||
mock = AsyncMock(return_value=rb)
|
||||
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.update_rulebook", mock):
|
||||
from fabledassistant.mcp.tools.rulebooks import update_rulebook
|
||||
await update_rulebook(rulebook_id=1, title="new title")
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert "always_on" not in kwargs
|
||||
assert kwargs["title"] == "new title"
|
||||
|
||||
@@ -44,13 +44,34 @@ def test_service_signatures_require_user_id():
|
||||
"create_rulebook", "list_rulebooks", "get_rulebook",
|
||||
"update_rulebook", "delete_rulebook", "find_rulebook_by_title",
|
||||
"create_topic", "list_topics", "get_topic", "update_topic", "delete_topic",
|
||||
"create_rule", "list_rules", "get_rule", "update_rule", "delete_rule",
|
||||
"create_rule", "list_rules", "list_always_on_rules",
|
||||
"get_rule", "update_rule", "delete_rule",
|
||||
"subscribe_project", "unsubscribe_project", "get_applicable_rules",
|
||||
):
|
||||
sig = inspect.signature(getattr(svc, fn_name))
|
||||
assert "user_id" in sig.parameters, f"{fn_name} missing user_id param"
|
||||
|
||||
|
||||
def test_rulebook_model_carries_always_on():
|
||||
"""Migration 0058 added rulebooks.always_on — verify the model declares it."""
|
||||
from fabledassistant.models.rulebook import Rulebook
|
||||
assert "always_on" in Rulebook.__table__.columns
|
||||
col = Rulebook.__table__.columns["always_on"]
|
||||
assert col.nullable is False
|
||||
|
||||
|
||||
def test_update_rulebook_route_accepts_always_on():
|
||||
"""PATCH /api/rulebooks/<id> must pass always_on through to the service.
|
||||
|
||||
The handler filters body keys against a whitelist; that whitelist needs to
|
||||
include always_on or toggling from the UI silently drops the field.
|
||||
"""
|
||||
import inspect as _inspect
|
||||
from fabledassistant.routes import rulebooks as rb_routes
|
||||
src = _inspect.getsource(rb_routes.update_rulebook)
|
||||
assert "always_on" in src, "update_rulebook handler missing always_on in field whitelist"
|
||||
|
||||
|
||||
def test_rule_and_subscription_handlers_callable():
|
||||
from fabledassistant.routes import rulebooks as rb_routes
|
||||
for name in (
|
||||
|
||||
Reference in New Issue
Block a user