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:
@@ -22,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;
|
||||
@@ -36,7 +37,7 @@ export interface RuleHeader {
|
||||
id: number;
|
||||
title: string;
|
||||
statement: string;
|
||||
topic_id: number;
|
||||
topic_id: number | null;
|
||||
}
|
||||
|
||||
export interface ApplicableRules {
|
||||
@@ -47,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 }[];
|
||||
}
|
||||
@@ -134,3 +140,10 @@ export async function unsubscribeProject(projectId: number, rulebookId: number):
|
||||
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
|
||||
return apiGet(`/api/projects/${projectId}/rules`);
|
||||
}
|
||||
|
||||
export async function createProjectRule(
|
||||
projectId: number,
|
||||
data: { statement: string; title?: string; why?: string; how_to_apply?: string },
|
||||
): Promise<Rule> {
|
||||
return apiPost(`/api/projects/${projectId}/rules`, data);
|
||||
}
|
||||
|
||||
@@ -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<Set<number>>(new Set());
|
||||
|
||||
const ruleDetails = ref<Record<number, { why: string; how_to_apply: string }>>({});
|
||||
|
||||
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);
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="project-rules">
|
||||
<div class="section-head">
|
||||
<h3>Project rules</h3>
|
||||
<button
|
||||
v-if="!showProjectRuleForm"
|
||||
class="add"
|
||||
@click="showProjectRuleForm = true"
|
||||
>
|
||||
+ New project rule
|
||||
</button>
|
||||
</div>
|
||||
<form v-if="showProjectRuleForm" class="new-rule-form" @submit.prevent="submitProjectRule">
|
||||
<input
|
||||
v-model="newProjectRule.title"
|
||||
placeholder="Title (optional — derived from statement if blank)"
|
||||
/>
|
||||
<textarea
|
||||
v-model="newProjectRule.statement"
|
||||
required
|
||||
autofocus
|
||||
placeholder="Statement (required) — the actionable instruction, 1-2 sentences"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<textarea
|
||||
v-model="newProjectRule.why"
|
||||
placeholder="Why (optional) — the rationale"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<textarea
|
||||
v-model="newProjectRule.how_to_apply"
|
||||
placeholder="How to apply (optional) — when / where it kicks in"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="form-buttons">
|
||||
<button type="submit">Create</button>
|
||||
<button type="button" @click="showProjectRuleForm = false">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
<ul v-if="applicable.project_rules && applicable.project_rules.length > 0" class="rule-list">
|
||||
<li v-for="r in applicable.project_rules" :key="r.id" class="rule">
|
||||
<div class="rule-head" @click="toggleRuleExpand(r.id)">
|
||||
<span class="rule-title">{{ r.title }}</span>
|
||||
<span class="rule-statement">{{ r.statement }}</span>
|
||||
</div>
|
||||
<div v-if="expandedRuleIds.has(r.id) && ruleDetails[r.id]" class="rule-detail">
|
||||
<div v-if="ruleDetails[r.id].why">
|
||||
<strong>Why:</strong> {{ ruleDetails[r.id].why }}
|
||||
</div>
|
||||
<div v-if="ruleDetails[r.id].how_to_apply">
|
||||
<strong>How to apply:</strong> {{ ruleDetails[r.id].how_to_apply }}
|
||||
</div>
|
||||
<button class="delete-link" @click="removeProjectRule(r.id)">Delete</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else-if="!showProjectRuleForm"
|
||||
class="empty"
|
||||
>
|
||||
No project-only rules yet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="applicable">
|
||||
<h3>Applicable rules</h3>
|
||||
<p v-if="applicable.rules.length === 0" class="empty">
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user