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
+15 -8
View File
@@ -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 —
+36 -2
View File
@@ -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)