feat(rules): project-scoped rules (S3)
Rules can now belong to either a rulebook topic OR a single project,
enforced by a CHECK constraint (exactly-one of topic_id/project_id).
Adds the create_project_rule MCP tool + REST endpoint, surfaces
project-scoped rules in get_project/get_task/start_planning under a
new project_rules field, and adds a project Rules tab section with an
inline create form so the operator can author project rules from the
UI without rulebook ceremony.
- migration 0059: rules.project_id (FK projects ON DELETE CASCADE),
topic_id now nullable, CHECK ck_rule_topic_xor_project, index on
project_id
- model: Rule gains project_id; to_dict exposes it
- service: create_project_rule with project-ownership guard; list_rules
with project_id filter UNIONs subscription-derived + project-scoped;
get_applicable_rules adds a project_rules field; get_rule / update_rule
/ delete_rule fetch via a shared _fetch_owned_rule that handles both
rulebook and project ownership paths
- trash: project delete cascades to project-scoped rules
- MCP: create_project_rule tool registered; _INSTRUCTIONS mentions both
create_rule and create_project_rule paths
- REST: POST /api/projects/<id>/rules (statement required, title derived
if omitted)
- frontend: Rule type gains nullable topic_id + project_id; createProjectRule
client; ProjectRulesTab.vue gains a "Project rules" section with inline
create form and per-rule expand/delete
- tests: register count → 18; create_project_rule unit tests (required
fields, title derivation, explicit-title pass-through); applicable_rules
shape tests now include project_rules; trash cascade test updated to
expect 5 executions
S1+S2 (always_on flag + Scribe-first prompt) shipped in 658348f.
S4 (enter_project handshake) follows.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -179,12 +179,13 @@ def test_register_attaches_all_sixteen_tools():
|
||||
return decorator
|
||||
|
||||
register(FakeMCP())
|
||||
assert len(registered) == 17
|
||||
assert len(registered) == 18
|
||||
# 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
|
||||
assert "create_project_rule" in registered
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -235,3 +236,51 @@ async def test_update_rulebook_omits_always_on_when_none():
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert "always_on" not in kwargs
|
||||
assert kwargs["title"] == "new title"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_rule_passes_required_fields():
|
||||
rule = _fake_rule()
|
||||
mock = AsyncMock(return_value=rule)
|
||||
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
|
||||
from fabledassistant.mcp.tools.rulebooks import create_project_rule
|
||||
await create_project_rule(
|
||||
project_id=42,
|
||||
statement="Always run migrations through alembic, not raw SQL.",
|
||||
why="audit trail",
|
||||
)
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert kwargs["user_id"] == 7
|
||||
assert kwargs["project_id"] == 42
|
||||
assert kwargs["statement"].startswith("Always run migrations")
|
||||
assert kwargs["why"] == "audit trail"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_rule_derives_title_from_statement():
|
||||
rule = _fake_rule()
|
||||
mock = AsyncMock(return_value=rule)
|
||||
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
|
||||
from fabledassistant.mcp.tools.rulebooks import create_project_rule
|
||||
await create_project_rule(
|
||||
project_id=42,
|
||||
statement="Avoid auto-generated docstrings. Reviewers find them noise.",
|
||||
)
|
||||
kwargs = mock.call_args.kwargs
|
||||
# Title should be derived from the first sentence, capped at 50 chars
|
||||
assert kwargs["title"] == "Avoid auto-generated docstrings"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_project_rule_uses_explicit_title_when_given():
|
||||
rule = _fake_rule()
|
||||
mock = AsyncMock(return_value=rule)
|
||||
with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.create_project_rule", mock):
|
||||
from fabledassistant.mcp.tools.rulebooks import create_project_rule
|
||||
await create_project_rule(
|
||||
project_id=42,
|
||||
statement="anything",
|
||||
title="no auto-docstrings",
|
||||
)
|
||||
kwargs = mock.call_args.kwargs
|
||||
assert kwargs["title"] == "no auto-docstrings"
|
||||
|
||||
Reference in New Issue
Block a user