feat(rules): project-scoped rules (S3)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 1m7s

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:
2026-06-01 01:10:18 -04:00
parent 658348f208
commit 43a860c3ac
16 changed files with 491 additions and 59 deletions
+8 -7
View File
@@ -47,13 +47,14 @@ project subscribes to) and subscribed_rulebooks; consult those too. Full text
(Why / How-to-apply) is available via get_rule(id).
Engineering and workflow rules live in Scribe. When you notice a pattern
worth codifying, call create_rule. Do NOT add new engineering rules to
CLAUDE.md or to ~/.claude/.../memory/feedback_*.md — those stores are
reserved for facts about the user (preferences, role, communication style)
and codebase onboarding pointers, respectively. Before creating a rule,
call list_always_on_rules and list_rules(project_id=...) to avoid duplicates.
Coordinate with the operator on whether a new rule belongs in an existing
rulebook+topic or a new one.
worth codifying, call create_rule (cross-project, lands in a rulebook+topic)
or create_project_rule (one project only, no rulebook ceremony). Do NOT add
new engineering rules to CLAUDE.md or to ~/.claude/.../memory/feedback_*.md
— those stores are reserved for facts about the user (preferences, role,
communication style) and codebase onboarding pointers, respectively. Before
creating a rule, call list_always_on_rules and list_rules(project_id=...) to
avoid duplicates. Coordinate with the operator on whether a new rule belongs
in a project, an existing rulebook+topic, or a new rulebook.
Plans are tasks with kind=plan, and Scribe is the canonical home for them.
When you begin non-trivial work, call start_planning(project_id, title) FIRST —
@@ -55,6 +55,7 @@ async def get_project(project_id: int) -> dict:
data["applicable_rules"] = applicable["rules"]
data["applicable_rules_truncated"] = applicable["truncated"]
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
data["project_rules"] = applicable.get("project_rules", [])
return data
+35 -2
View File
@@ -247,7 +247,7 @@ async def create_rule(
topic_id: int, title: str, statement: str,
why: str = "", how_to_apply: str = "", order_index: int = 0,
) -> dict:
"""Create a new rule under a topic.
"""Create a new rule under a topic (cross-project rulebook rule).
Args:
topic_id: The topic to attach the rule to.
@@ -256,6 +256,9 @@ async def create_rule(
why: Optional rationale — the reason the rule exists.
how_to_apply: Optional operationalization — when / where it kicks in.
order_index: Display order within the topic (default 0).
For a rule that applies to a single project only, use create_project_rule
instead — no rulebook+topic ceremony required.
"""
uid = current_user_id()
rule = await rulebooks_svc.create_rule(
@@ -266,6 +269,35 @@ async def create_rule(
return rule.to_dict()
async def create_project_rule(
project_id: int, statement: str, title: str = "",
why: str = "", how_to_apply: str = "", order_index: int = 0,
) -> dict:
"""Create a rule scoped to a single project (no rulebook needed).
Use this when a rule only applies to one project — it bypasses the
Rulebook -> Topic -> Rule ceremony. The rule is returned in get_project's
applicable_rules (under project_rules) and in list_rules(project_id=...).
Args:
project_id: The project to attach the rule to.
statement: The actionable instruction (required). 1-2 sentences.
title: Short imperative title. If empty, derived from the first ~50
characters of statement.
why: Optional rationale — the reason the rule exists.
how_to_apply: Optional operationalization — when / where it kicks in.
order_index: Display order within the project's rule list (default 0).
"""
uid = current_user_id()
derived_title = title.strip() or statement.strip().split(".")[0][:50]
rule = await rulebooks_svc.create_project_rule(
project_id=project_id, user_id=uid,
title=derived_title, statement=statement,
why=why, how_to_apply=how_to_apply, order_index=order_index,
)
return rule.to_dict()
async def update_rule(
rule_id: int, title: str = "", statement: str = "",
why: str = "", how_to_apply: str = "", order_index: int = -1,
@@ -336,7 +368,8 @@ def register(mcp) -> None:
for fn in (
list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
list_topics, create_topic, update_topic, delete_topic,
list_rules, list_always_on_rules, get_rule, create_rule, update_rule, delete_rule,
list_rules, list_always_on_rules, get_rule,
create_rule, create_project_rule, update_rule, delete_rule,
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
):
mcp.tool(name=fn.__name__)(fn)
+1
View File
@@ -82,6 +82,7 @@ async def get_task(task_id: int) -> dict:
data["applicable_rules"] = applicable["rules"]
data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
data["applicable_rules_truncated"] = applicable["truncated"]
data["project_rules"] = applicable.get("project_rules", [])
return data