feat(rules): always_on rulebook flag + Scribe-first prompt
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 1m1s

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:
2026-06-01 00:56:08 -04:00
parent fd20b67b22
commit 658348f208
12 changed files with 243 additions and 21 deletions
+52 -1
View File
@@ -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"