diff --git a/alembic/versions/0058_rulebook_always_on.py b/alembic/versions/0058_rulebook_always_on.py new file mode 100644 index 0000000..0607aae --- /dev/null +++ b/alembic/versions/0058_rulebook_always_on.py @@ -0,0 +1,39 @@ +"""rulebook always_on flag + +Revision ID: 0058 +Revises: 0057 +Create Date: 2026-06-01 + +Adds a boolean `always_on` to the `rulebooks` table. Rules from rulebooks +flagged always_on are loaded at session start by the new +`list_always_on_rules` MCP tool — they apply regardless of which project +(if any) is in scope. Seeds the FabledSword family rulebook to always_on +because that's the cross-project standards rulebook by design. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0058" +down_revision = "0057" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "rulebooks", + sa.Column( + "always_on", + sa.Boolean(), + nullable=False, + server_default=sa.text("false"), + ), + ) + op.execute( + "UPDATE rulebooks SET always_on = TRUE WHERE title = 'FabledSword family'" + ) + + +def downgrade() -> None: + op.drop_column("rulebooks", "always_on") diff --git a/alembic/versions/0059_rule_project_scope.py b/alembic/versions/0059_rule_project_scope.py new file mode 100644 index 0000000..04d49ae --- /dev/null +++ b/alembic/versions/0059_rule_project_scope.py @@ -0,0 +1,49 @@ +"""project-scoped rules + +Revision ID: 0059 +Revises: 0058 +Create Date: 2026-06-01 + +Rules can now belong to either a rulebook topic (cross-project standard) or +a single project (project-scoped). Adds `rules.project_id`, makes `topic_id` +nullable, and adds a CHECK constraint enforcing exactly-one. The previous +unique constraint on (topic_id, title) still applies because PostgreSQL +treats NULL as distinct — two project-scoped rules with the same title and +NULL topic_id remain unique. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0059" +down_revision = "0058" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "rules", + sa.Column( + "project_id", + sa.BigInteger(), + sa.ForeignKey("projects.id", ondelete="CASCADE"), + nullable=True, + ), + ) + op.alter_column("rules", "topic_id", nullable=True) + op.create_index("ix_rules_project_id", "rules", ["project_id"]) + op.create_check_constraint( + "ck_rule_topic_xor_project", + "rules", + "(topic_id IS NULL) <> (project_id IS NULL)", + ) + + +def downgrade() -> None: + op.drop_constraint("ck_rule_topic_xor_project", "rules", type_="check") + op.drop_index("ix_rules_project_id", table_name="rules") + # Any rule with NULL topic_id will block re-tightening. Operator must + # migrate or delete project-scoped rules before downgrading. + op.alter_column("rules", "topic_id", nullable=False) + op.drop_column("rules", "project_id") diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts index 77685a6..d048290 100644 --- a/frontend/src/api/rulebooks.ts +++ b/frontend/src/api/rulebooks.ts @@ -5,6 +5,7 @@ export interface Rulebook { owner_user_id: number; title: string; description: string; + always_on: boolean; created_at: string | null; updated_at: string | null; } @@ -21,7 +22,8 @@ export interface RulebookTopic { export interface Rule { id: number; - topic_id: number; + topic_id: number | null; + project_id: number | null; title: string; statement: string; why: string; @@ -35,7 +37,7 @@ export interface RuleHeader { id: number; title: string; statement: string; - topic_id: number; + topic_id: number | null; } export interface ApplicableRules { @@ -46,6 +48,11 @@ export interface ApplicableRules { topic_title: string; rulebook_title: string; }[]; + project_rules: { + id: number; + title: string; + statement: string; + }[]; truncated: boolean; subscribed_rulebooks: { id: number; title: string }[]; } @@ -65,7 +72,7 @@ export async function createRulebook(data: { title: string; description?: string return apiPost("/api/rulebooks", data); } -export async function updateRulebook(id: number, data: Partial<{ title: string; description: string }>): Promise { +export async function updateRulebook(id: number, data: Partial<{ title: string; description: string; always_on: boolean }>): Promise { return apiPatch(`/api/rulebooks/${id}`, data); } @@ -133,3 +140,10 @@ export async function unsubscribeProject(projectId: number, rulebookId: number): export async function getProjectApplicableRules(projectId: number): Promise { return apiGet(`/api/projects/${projectId}/rules`); } + +export async function createProjectRule( + projectId: number, + data: { statement: string; title?: string; why?: string; how_to_apply?: string }, +): Promise { + return apiPost(`/api/projects/${projectId}/rules`, data); +} diff --git a/frontend/src/components/rules/ProjectRulesTab.vue b/frontend/src/components/rules/ProjectRulesTab.vue index 955b58c..65e4f24 100644 --- a/frontend/src/components/rules/ProjectRulesTab.vue +++ b/frontend/src/components/rules/ProjectRulesTab.vue @@ -3,7 +3,7 @@ import { ref, onMounted, watch } from "vue"; import { useRouter } from "vue-router"; import { getProjectApplicableRules, subscribeProject, unsubscribeProject, - listRulebooks, getRule, + listRulebooks, getRule, createProjectRule, deleteRule, } from "@/api/rulebooks"; import type { ApplicableRules, Rulebook } from "@/api/rulebooks"; @@ -16,6 +16,9 @@ const expandedRuleIds = ref>(new Set()); const ruleDetails = ref>({}); +const showProjectRuleForm = ref(false); +const newProjectRule = ref({ title: "", statement: "", why: "", how_to_apply: "" }); + async function load() { applicable.value = await getProjectApplicableRules(props.projectId); } @@ -73,6 +76,26 @@ function rulebookIdForTitle(title: string): number | undefined { return applicable.value?.subscribed_rulebooks.find((rb) => rb.title === title)?.id; } +async function submitProjectRule() { + const statement = newProjectRule.value.statement.trim(); + if (!statement) return; + await createProjectRule(props.projectId, { + statement, + title: newProjectRule.value.title.trim() || undefined, + why: newProjectRule.value.why.trim() || undefined, + how_to_apply: newProjectRule.value.how_to_apply.trim() || undefined, + }); + newProjectRule.value = { title: "", statement: "", why: "", how_to_apply: "" }; + showProjectRuleForm.value = false; + await load(); +} + +async function removeProjectRule(ruleId: number) { + if (!confirm("Delete this project rule? It will move to the trash.")) return; + await deleteRule(ruleId); + await load(); +} + onMounted(async () => { await load(); await loadAllRulebooks(); @@ -111,6 +134,69 @@ watch(() => props.projectId, load); +
+
+

Project rules

+ +
+
+ + + + +
+ + +
+
+
    +
  • +
    + {{ r.title }} + {{ r.statement }} +
    +
    +
    + Why: {{ ruleDetails[r.id].why }} +
    +
    + How to apply: {{ ruleDetails[r.id].how_to_apply }} +
    + +
    +
  • +
+

+ No project-only rules yet. +

+
+

Applicable rules

@@ -208,4 +294,22 @@ ul { list-style: none; padding: 0; margin: 0; } } .empty, .truncated { opacity: 0.7; font-style: italic; } .empty a { cursor: pointer; text-decoration: underline; } +.project-rules { margin-top: 1.5rem; } +.section-head { display: flex; justify-content: space-between; align-items: center; } +.new-rule-form { + display: flex; flex-direction: column; gap: 0.5rem; + padding: 0.75rem; margin: 0.5rem 0; + background: var(--color-bg, #111113); + border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px; +} +.new-rule-form input, .new-rule-form textarea { + background: var(--color-surface, #18181b); color: inherit; + border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px; + padding: 0.5rem; font: inherit; resize: vertical; +} +.rule-list { margin-top: 0.5rem; } +.delete-link { + background: none; border: none; cursor: pointer; + color: var(--color-destructive, #b85a4a); padding: 0.5rem 0 0 0; +} diff --git a/frontend/src/components/rules/RulebookDetailPane.vue b/frontend/src/components/rules/RulebookDetailPane.vue index 56be664..ab69d29 100644 --- a/frontend/src/components/rules/RulebookDetailPane.vue +++ b/frontend/src/components/rules/RulebookDetailPane.vue @@ -1,5 +1,5 @@