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:
@@ -39,14 +39,21 @@ Mechanics:
|
||||
"not set".
|
||||
|
||||
Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry
|
||||
an actionable statement plus optional Why and How-to-apply context. When you
|
||||
start work on a project, get_project(id) returns applicable_rules (the rules
|
||||
from rulebooks the project subscribes to) and subscribed_rulebooks. Consult
|
||||
these before making decisions about workflow, conventions, or scope. Full
|
||||
text (Why / How-to-apply) is available via get_rule(id). You may create new
|
||||
rules via create_rule when you notice a pattern worth codifying — coordinate
|
||||
with the operator on whether it belongs in an existing rulebook+topic or a
|
||||
new one.
|
||||
an actionable statement plus optional Why and How-to-apply context. At the
|
||||
start of any session that touches Scribe, call list_always_on_rules() to
|
||||
load the standing rules — treat them as binding. When you also have a project
|
||||
in scope, get_project(id) returns applicable_rules (rules from rulebooks the
|
||||
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.
|
||||
|
||||
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 —
|
||||
|
||||
@@ -54,14 +54,26 @@ async def create_rulebook(title: str, description: str = "") -> dict:
|
||||
|
||||
async def update_rulebook(
|
||||
rulebook_id: int, title: str = "", description: str = "",
|
||||
always_on: bool | None = None,
|
||||
) -> dict:
|
||||
"""Update an existing rulebook. Only non-empty fields are changed."""
|
||||
"""Update an existing rulebook. Only non-empty fields are changed.
|
||||
|
||||
Args:
|
||||
rulebook_id: Rulebook to update.
|
||||
title: New title. Empty string leaves unchanged.
|
||||
description: New description. Empty string leaves unchanged.
|
||||
always_on: When True, rules in this rulebook are loaded at session
|
||||
start by list_always_on_rules regardless of project context.
|
||||
Pass None to leave unchanged.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
fields: dict = {}
|
||||
if title:
|
||||
fields["title"] = title
|
||||
if description:
|
||||
fields["description"] = description
|
||||
if always_on is not None:
|
||||
fields["always_on"] = always_on
|
||||
rb = await rulebooks_svc.update_rulebook(rulebook_id, uid, **fields)
|
||||
if rb is None:
|
||||
raise ValueError(f"rulebook {rulebook_id} not found")
|
||||
@@ -200,6 +212,28 @@ async def list_rules(
|
||||
}
|
||||
|
||||
|
||||
async def list_always_on_rules() -> 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.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
rules = await rulebooks_svc.list_always_on_rules(uid)
|
||||
return {
|
||||
"rules": [
|
||||
{
|
||||
"id": r.id, "title": r.title, "statement": r.statement,
|
||||
"topic_id": r.topic_id,
|
||||
}
|
||||
for r in rules
|
||||
],
|
||||
"total": len(rules),
|
||||
}
|
||||
|
||||
|
||||
async def get_rule(rule_id: int) -> dict:
|
||||
"""Fetch a rule by id — full statement + why + how_to_apply."""
|
||||
uid = current_user_id()
|
||||
@@ -302,7 +336,7 @@ 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, get_rule, create_rule, update_rule, delete_rule,
|
||||
list_rules, list_always_on_rules, get_rule, create_rule, update_rule, delete_rule,
|
||||
subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
|
||||
):
|
||||
mcp.tool(name=fn.__name__)(fn)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, Integer, Table, Text, UniqueConstraint
|
||||
from sqlalchemy import BigInteger, Boolean, Column, DateTime, ForeignKey, Integer, Table, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from fabledassistant.models import Base
|
||||
@@ -16,6 +16,9 @@ class Rulebook(Base, SoftDeleteMixin):
|
||||
)
|
||||
title: Mapped[str] = mapped_column(Text)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
always_on: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, nullable=False, server_default="false"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -31,6 +34,7 @@ class Rulebook(Base, SoftDeleteMixin):
|
||||
"owner_user_id": self.owner_user_id,
|
||||
"title": self.title,
|
||||
"description": self.description or "",
|
||||
"always_on": self.always_on,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ async def get_rulebook(rulebook_id: int):
|
||||
@login_required
|
||||
async def update_rulebook(rulebook_id: int):
|
||||
data = await request.get_json() or {}
|
||||
fields = {k: v for k, v in data.items() if k in ("title", "description")}
|
||||
fields = {k: v for k, v in data.items() if k in ("title", "description", "always_on")}
|
||||
rb = await rulebooks_svc.update_rulebook(rulebook_id, _uid(), **fields)
|
||||
if rb is None:
|
||||
return jsonify({"error": "rulebook not found"}), 404
|
||||
|
||||
@@ -72,7 +72,7 @@ async def update_rulebook(
|
||||
rb = result.scalar_one_or_none()
|
||||
if rb is None:
|
||||
return None
|
||||
allowed = {"title", "description"}
|
||||
allowed = {"title", "description", "always_on"}
|
||||
for key, value in fields.items():
|
||||
if key in allowed and value is not None:
|
||||
setattr(rb, key, value)
|
||||
@@ -305,6 +305,33 @@ async def list_rules(
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def list_always_on_rules(user_id: int, limit: int = 100) -> 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.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Rule)
|
||||
.join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
|
||||
.join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
|
||||
.where(
|
||||
Rulebook.owner_user_id == user_id,
|
||||
Rulebook.always_on.is_(True),
|
||||
Rule.deleted_at.is_(None),
|
||||
RulebookTopic.deleted_at.is_(None),
|
||||
Rulebook.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(
|
||||
Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
|
||||
)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_rule(rule_id: int, user_id: int) -> Optional[Rule]:
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
|
||||
Reference in New Issue
Block a user