CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 54s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m41s
CI & Build / Build & push image (push) Successful in 33s
A rule's home is its reach: a rulebook topic makes it global, a project makes it that project's. There was no way to change one, so a project rule decided to be global could only be recreated and the original trashed — losing the id every record cites, its edit history, its area tags and its relations. - services.rulebooks.move_rule(rule_id, user_id, topic_id= | project_id=): exactly one destination (the model's CHECK), owned by the caller, not the rule's current home. A topic already holding a live rule with the same title is refused with a message naming that rule, instead of uq_rule_per_topic failing the commit. Someone else's rule reads as not found. - Deliberately NOT done, and said in the docstring: no version (a version is what a rule said, milestone 323 decision 4), no duplicate gate (nothing new enters the corpus), no re-embed (retrieval reads the home at query time). - Both doors: MCP move_rule, REST POST /api/rules/<id>/move (rule 33). - UI: RuleHomePicker, one component in the rule editor (a global rule) and a project's rules tab (a project rule), so the two cannot drift on what a destination is. - using-scribe names move_rule under "Where a new rule goes". Plugin 2026.09.15.1626. Milestone 414 step 3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
import type { RecordUsage } from "@/types/usage";
|
|
|
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
|
|
|
/** How a rule reaches a session (milestone 307). */
|
|
|
|
/**
|
|
* A typed edge between two rules. Each kind exists because its absence forced
|
|
* a workaround: merging two rules into one row, writing an override as a
|
|
* near-copy, or leaving a local addendum with nothing to say it is one.
|
|
*/
|
|
export type RuleRelationKind = "co_surfaces" | "overrides" | "elaborates";
|
|
|
|
export interface RuleRelation {
|
|
id: number;
|
|
kind: RuleRelationKind;
|
|
/** The rule at the OTHER end. */
|
|
rule_id: number;
|
|
direction: "outgoing" | "incoming";
|
|
note: string;
|
|
}
|
|
|
|
export interface Rulebook {
|
|
id: number;
|
|
owner_user_id: number;
|
|
title: string;
|
|
description: string;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
}
|
|
|
|
export interface RulebookTopic {
|
|
id: number;
|
|
rulebook_id: number;
|
|
title: string;
|
|
description: string;
|
|
order_index: number;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
}
|
|
|
|
export interface Rule {
|
|
id: number;
|
|
topic_id: number | null;
|
|
project_id: number | null;
|
|
title: string;
|
|
statement: string;
|
|
/** WHEN this rule fires — the trigger, not the instruction. */
|
|
when_to_apply: string;
|
|
why: string;
|
|
how_to_apply: string;
|
|
/**
|
|
* How to check the rule is still true, and the state that ends it. Set
|
|
* only on a rule that asserts a fact about something outside the
|
|
* operator's control; empty on a rule that is a decision, which is most
|
|
* of them. Empty is meaningful, not missing.
|
|
*/
|
|
verify_with: string;
|
|
expires_when: string;
|
|
/** When the check last passed. Null means never checked. */
|
|
verified_at: string | null;
|
|
/** The note or task that caused this rule, if one was recorded. */
|
|
arose_from_id: number | null;
|
|
order_index: number;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
/** Present only when the rule has them (the server omits empty keys). */
|
|
systems?: { id: number; name: string }[];
|
|
relations?: RuleRelation[];
|
|
}
|
|
|
|
/**
|
|
* A rule as a LIST ROW — services.rulebooks.rule_brief's output. Carries the
|
|
* age deliberately: a rule written before the capability it duplicates is
|
|
* otherwise indistinguishable, at a glance, from one still doing work.
|
|
*/
|
|
export interface RuleHeader {
|
|
id: number;
|
|
title: string;
|
|
statement: string;
|
|
topic_id: number | null;
|
|
/** A date (YYYY-MM-DD), not a timestamp. */
|
|
updated_at: string | null;
|
|
when_to_apply?: string;
|
|
arose_from_id?: number;
|
|
/**
|
|
* Present ONLY on a rule that carries a check — the presence of the key
|
|
* is itself the signal that this rule asserts a fact that can go false.
|
|
* A date (YYYY-MM-DD), or the literal "never".
|
|
*/
|
|
last_verified?: string;
|
|
/**
|
|
* Surfaced-vs-opened counts from `rule_usage_events` (milestone 333).
|
|
* Zero-filled by the list route, so a rule predating the table reads as
|
|
* "never surfaced" rather than as a missing field — which for a while is
|
|
* every rule on every install.
|
|
*/
|
|
usage?: RecordUsage;
|
|
}
|
|
|
|
export interface ApplicableRules {
|
|
// Both lists are rule_brief's output — the SAME builder, so they are
|
|
// described the same way here rather than as two hand-written shapes that
|
|
// drift from it and from each other (which is what the server side had).
|
|
rules: (RuleHeader & {
|
|
topic_title: string;
|
|
rulebook_id: number;
|
|
rulebook_title: string;
|
|
})[];
|
|
project_rules: RuleHeader[];
|
|
truncated: boolean;
|
|
}
|
|
|
|
// ── Rulebooks ───────────────────────────────────────────────────────
|
|
|
|
export async function listRulebooks(): Promise<Rulebook[]> {
|
|
const data = await apiGet<{ rulebooks: Rulebook[] }>("/api/rulebooks");
|
|
return data.rulebooks;
|
|
}
|
|
|
|
export async function getRulebook(id: number): Promise<Rulebook & { topics: RulebookTopic[] }> {
|
|
return apiGet(`/api/rulebooks/${id}`);
|
|
}
|
|
|
|
export async function createRulebook(data: { title: string; description?: string }): Promise<Rulebook> {
|
|
return apiPost("/api/rulebooks", data);
|
|
}
|
|
|
|
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string }>): Promise<Rulebook> {
|
|
return apiPatch(`/api/rulebooks/${id}`, data);
|
|
}
|
|
|
|
export async function deleteRulebook(id: number): Promise<void> {
|
|
return apiDelete(`/api/rulebooks/${id}`);
|
|
}
|
|
|
|
// ── Topics ─────────────────────────────────────────────────────────
|
|
|
|
export async function listTopics(rulebookId: number): Promise<RulebookTopic[]> {
|
|
const data = await apiGet<{ topics: RulebookTopic[] }>(`/api/rulebooks/${rulebookId}/topics`);
|
|
return data.topics;
|
|
}
|
|
|
|
export async function createTopic(rulebookId: number, data: { title: string; description?: string; order_index?: number }): Promise<RulebookTopic> {
|
|
return apiPost(`/api/rulebooks/${rulebookId}/topics`, data);
|
|
}
|
|
|
|
export async function updateTopic(id: number, data: Partial<{ title: string; description: string; order_index: number }>): Promise<RulebookTopic> {
|
|
return apiPatch(`/api/rulebook-topics/${id}`, data);
|
|
}
|
|
|
|
export async function deleteTopic(id: number): Promise<void> {
|
|
return apiDelete(`/api/rulebook-topics/${id}`);
|
|
}
|
|
|
|
// ── Rules ──────────────────────────────────────────────────────────
|
|
|
|
export async function listRules(filters: { rulebook_id?: number; topic_id?: number; project_id?: number } = {}): Promise<Rule[]> {
|
|
const params = new URLSearchParams();
|
|
if (filters.rulebook_id) params.set("rulebook_id", String(filters.rulebook_id));
|
|
if (filters.topic_id) params.set("topic_id", String(filters.topic_id));
|
|
if (filters.project_id) params.set("project_id", String(filters.project_id));
|
|
const qs = params.toString();
|
|
const data = await apiGet<{ rules: Rule[] }>(`/api/rules${qs ? `?${qs}` : ""}`);
|
|
return data.rules;
|
|
}
|
|
|
|
export async function getRule(id: number): Promise<Rule> {
|
|
return apiGet(`/api/rules/${id}`);
|
|
}
|
|
|
|
/**
|
|
* The fields both write paths accept. `system_ids` REPLACES a rule's areas.
|
|
*
|
|
* Sending "" for a nullable text field CLEARS it here — the server maps an
|
|
* empty string to NULL, so an emptied form input does what it looks like it
|
|
* does. (The MCP door reads "" as "leave unchanged" and needs an explicit
|
|
* clear_fields list instead; the two idioms reach the same state.)
|
|
*/
|
|
export interface RuleWrite {
|
|
title: string;
|
|
statement: string;
|
|
when_to_apply: string;
|
|
why: string;
|
|
how_to_apply: string;
|
|
order_index: number;
|
|
system_ids: number[];
|
|
arose_from_id: number | null;
|
|
verify_with: string;
|
|
expires_when: string;
|
|
}
|
|
|
|
export async function createRule(topicId: number, data: Partial<RuleWrite> & { title: string; statement: string }): Promise<Rule> {
|
|
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
|
|
}
|
|
|
|
export async function updateRule(id: number, data: Partial<RuleWrite>): Promise<Rule> {
|
|
return apiPatch(`/api/rules/${id}`, data);
|
|
}
|
|
|
|
/** Give a rule a new home: a topic makes it global, a project makes it that
|
|
* project's. Keeps its id, history, areas and relations (milestone 414). */
|
|
export async function moveRule(
|
|
id: number, to: { topic_id: number } | { project_id: number },
|
|
): Promise<Rule> {
|
|
return apiPost(`/api/rules/${id}/move`, to);
|
|
}
|
|
|
|
/** Draw a typed edge from one rule to another. Idempotent. */
|
|
export async function relateRules(
|
|
fromRuleId: number,
|
|
data: { to_rule_id: number; kind: RuleRelationKind; note?: string },
|
|
): Promise<{ id: number }> {
|
|
return apiPost(`/api/rules/${fromRuleId}/relations`, data);
|
|
}
|
|
|
|
export async function unrelateRules(relationId: number): Promise<void> {
|
|
return apiDelete(`/api/rule-relations/${relationId}`);
|
|
}
|
|
|
|
/**
|
|
* One entry in a rule's edit history.
|
|
*
|
|
* Each entry holds the text the edit REPLACED, not the text it introduced —
|
|
* so the newest entry is what the rule said before its most recent change,
|
|
* and what that change produced is the rule as it stands now. Read the other
|
|
* way round, every diff comes out backwards.
|
|
*
|
|
* The listing form omits the long fields; open one to get them.
|
|
*/
|
|
export interface RuleVersion {
|
|
id: number;
|
|
rule_id: number;
|
|
/** Who made the edit. Null when that account has since been deleted. */
|
|
user_id: number | null;
|
|
title: string;
|
|
created_at: string;
|
|
statement?: string;
|
|
why?: string;
|
|
how_to_apply?: string;
|
|
when_to_apply?: string;
|
|
verify_with?: string;
|
|
expires_when?: string;
|
|
}
|
|
|
|
export async function listRuleVersions(ruleId: number): Promise<RuleVersion[]> {
|
|
const data = await apiGet<{ versions: RuleVersion[] }>(
|
|
`/api/rules/${ruleId}/versions`,
|
|
);
|
|
return data.versions;
|
|
}
|
|
|
|
export async function getRuleVersion(
|
|
ruleId: number, versionId: number,
|
|
): Promise<RuleVersion> {
|
|
return apiGet<RuleVersion>(`/api/rules/${ruleId}/versions/${versionId}`);
|
|
}
|
|
|
|
// No restoreRuleVersion, deliberately (milestone 323). Putting an old wording
|
|
// back goes through updateRule, which snapshots what it replaces — so the
|
|
// undo stays visible in the history like any other edit.
|
|
|
|
export async function deleteRule(id: number): Promise<void> {
|
|
return apiDelete(`/api/rules/${id}`);
|
|
}
|
|
|
|
// ── A project's rules ──────────────────────────────────────────────
|
|
|
|
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
|
|
return apiGet(`/api/projects/${projectId}/rules`);
|
|
}
|
|
|
|
export async function createProjectRule(
|
|
projectId: number,
|
|
data: Partial<RuleWrite> & { statement: string },
|
|
): Promise<Rule> {
|
|
return apiPost(`/api/projects/${projectId}/rules`, data);
|
|
}
|
|
|
|
|
|
/**
|
|
* One row of the staleness sweep. Unlike RuleHeader this carries the CHECK
|
|
* in full — the reader is about to go and run it, so the text is the point
|
|
* of the payload rather than the bloat a listing avoids.
|
|
*/
|
|
export interface RuleVerificationRow {
|
|
id: number;
|
|
title: string;
|
|
statement: string;
|
|
topic_id: number | null;
|
|
project_id: number | null;
|
|
when_to_apply: string;
|
|
verify_with: string;
|
|
expires_when: string;
|
|
/** A date (YYYY-MM-DD), or the literal "never". */
|
|
last_verified: string | null;
|
|
/** Null when never verified — "never" is not zero days ago. */
|
|
days_since_verified: number | null;
|
|
}
|
|
|
|
/**
|
|
* Rules asserting a fact that may have gone false, oldest verification
|
|
* first, never-checked at the top. Rules without a check never appear:
|
|
* they are decisions, and there is nothing to go and check.
|
|
*
|
|
* Not filterable by project — a project is bound by its own rules and by
|
|
* every global rule, and a filter that dropped the global ones would
|
|
* under-report.
|
|
*/
|
|
export async function listRulesDueForVerification(opts: {
|
|
olderThanDays?: number;
|
|
neverOnly?: boolean;
|
|
} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> {
|
|
const q = new URLSearchParams();
|
|
if (opts.olderThanDays) q.set("older_than_days", String(opts.olderThanDays));
|
|
if (opts.neverOnly) q.set("never_only", "true");
|
|
const qs = q.toString();
|
|
return apiGet(`/api/rules-due-for-verification${qs ? `?${qs}` : ""}`);
|
|
}
|
|
|
|
/**
|
|
* Record that a rule's check was RUN, and what it said.
|
|
*
|
|
* `stillTrue: false` writes nothing on purpose — a rule whose check failed
|
|
* is not in a recordable state, it is wrong — so it stays at the top of the
|
|
* sweep until someone corrects or retires it.
|
|
*/
|
|
export async function markRuleVerified(
|
|
id: number, stillTrue = true,
|
|
): Promise<Rule & { verified: boolean }> {
|
|
return apiPost(`/api/rules/${id}/verify`, { still_true: stillTrue });
|
|
}
|