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
350 lines
14 KiB
Vue
350 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, watch, onMounted } from "vue";
|
||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
||
import RuleHomePicker from "@/components/rules/RuleHomePicker.vue";
|
||
import type { Rule } from "@/api/rulebooks";
|
||
|
||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||
const emit = defineEmits<{ close: [] }>();
|
||
|
||
const store = useRulebooksStore();
|
||
const canon = useCanonicalSystemsStore();
|
||
const title = ref("");
|
||
const statement = ref("");
|
||
const whenToApply = ref("");
|
||
const systemIds = ref<number[]>([]);
|
||
const why = ref("");
|
||
const howToApply = ref("");
|
||
const verifyWith = ref("");
|
||
const expiresWhen = ref("");
|
||
|
||
const relations = computed(() => store.currentRule?.relations ?? []);
|
||
|
||
// The label a reader needs to judge an edge, not the stored token.
|
||
const RELATION_LABEL: Record<string, { outgoing: string; incoming: string }> = {
|
||
co_surfaces: { outgoing: "arrives with", incoming: "arrives with" },
|
||
overrides: { outgoing: "overrides", incoming: "is overridden by" },
|
||
elaborates: { outgoing: "elaborates", incoming: "is elaborated by" },
|
||
};
|
||
|
||
function relationLabel(kind: string, direction: "outgoing" | "incoming") {
|
||
return RELATION_LABEL[kind]?.[direction] ?? kind;
|
||
}
|
||
|
||
function toggleSystem(id: number) {
|
||
const at = systemIds.value.indexOf(id);
|
||
if (at >= 0) systemIds.value.splice(at, 1);
|
||
else systemIds.value.push(id);
|
||
}
|
||
|
||
const isCreating = ref(props.ruleId === null);
|
||
|
||
// The stored stamp, not the draft: it describes the check that was RUN, and
|
||
// an unsaved edit to the textarea has not been run against anything.
|
||
const verifiedAt = computed(() => store.currentRule?.verified_at ?? null);
|
||
const savedCheck = computed(() => store.currentRule?.verify_with ?? "");
|
||
// Built here rather than in the template: same shape as the server's
|
||
// last_verified_label, and it keeps the null-narrowing in TypeScript's reach.
|
||
const stampLabel = computed(() =>
|
||
verifiedAt.value ? `Last checked ${verifiedAt.value.slice(0, 10)}` : "Never checked",
|
||
);
|
||
const verifying = ref(false);
|
||
|
||
async function verify(stillTrue: boolean) {
|
||
if (props.ruleId === null) return;
|
||
verifying.value = true;
|
||
try {
|
||
await store.verifyRule(props.ruleId, stillTrue);
|
||
} finally {
|
||
verifying.value = false;
|
||
}
|
||
}
|
||
|
||
async function load() {
|
||
if (props.ruleId !== null) {
|
||
await store.fetchRule(props.ruleId);
|
||
const r = store.currentRule;
|
||
if (r) {
|
||
title.value = r.title;
|
||
statement.value = r.statement;
|
||
whenToApply.value = r.when_to_apply || "";
|
||
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
||
why.value = r.why || "";
|
||
howToApply.value = r.how_to_apply || "";
|
||
verifyWith.value = r.verify_with || "";
|
||
expiresWhen.value = r.expires_when || "";
|
||
}
|
||
} else {
|
||
title.value = "";
|
||
statement.value = "";
|
||
whenToApply.value = "";
|
||
systemIds.value = [];
|
||
why.value = "";
|
||
howToApply.value = "";
|
||
verifyWith.value = "";
|
||
expiresWhen.value = "";
|
||
}
|
||
await canon.fetchCatalog();
|
||
}
|
||
|
||
async function save() {
|
||
if (!title.value.trim() || !statement.value.trim()) {
|
||
emit("close");
|
||
return;
|
||
}
|
||
const fields = {
|
||
title: title.value,
|
||
statement: statement.value,
|
||
when_to_apply: whenToApply.value,
|
||
// Always sent, so clearing the last area actually clears it — the server
|
||
// reads a list as "these ARE the areas now".
|
||
system_ids: systemIds.value,
|
||
why: why.value,
|
||
how_to_apply: howToApply.value,
|
||
// Always sent, including empty. The REST door maps "" to NULL, so
|
||
// clearing a field here actually clears it — the MCP door's "" means
|
||
// "leave unchanged" and needs an explicit clear_fields list instead.
|
||
verify_with: verifyWith.value,
|
||
expires_when: expiresWhen.value,
|
||
};
|
||
if (isCreating.value && props.topicId !== null) {
|
||
await store.createRule(props.topicId, fields);
|
||
} else if (props.ruleId !== null) {
|
||
await store.updateRule(props.ruleId, fields);
|
||
}
|
||
emit("close");
|
||
}
|
||
|
||
// A moved rule has left the topic this view lists (or joined another). The
|
||
// store re-places it, then the editor saves any text edits and closes, the
|
||
// way the backdrop does.
|
||
async function onMoved(rule: Rule) {
|
||
store.placeMovedRule(rule);
|
||
await save();
|
||
}
|
||
|
||
async function remove() {
|
||
if (props.ruleId === null) return;
|
||
if (!confirm("Delete this rule? This cannot be undone.")) return;
|
||
await store.deleteRule(props.ruleId);
|
||
emit("close");
|
||
}
|
||
|
||
onMounted(load);
|
||
watch(() => props.ruleId, load);
|
||
</script>
|
||
|
||
<template>
|
||
<div class="backdrop" @click="save">
|
||
<aside class="slide-over" @click.stop>
|
||
<header>
|
||
<h2>{{ isCreating ? "New rule" : "Edit rule" }}</h2>
|
||
<button v-if="!isCreating" class="trash" @click="remove" aria-label="Delete">🗑</button>
|
||
<button class="close" @click="save" aria-label="Close">×</button>
|
||
</header>
|
||
<label>
|
||
Title
|
||
<input v-model="title" placeholder="e.g. dev is home" />
|
||
</label>
|
||
<label>
|
||
Statement <span class="required">*</span>
|
||
<textarea v-model="statement" rows="3" placeholder="The actionable instruction (1-2 sentences)." />
|
||
</label>
|
||
<label :class="{ 'trigger-missing': !whenToApply.trim() }">
|
||
When to apply <span class="required">*</span>
|
||
<textarea
|
||
v-model="whenToApply"
|
||
rows="3"
|
||
placeholder="The moment, in the words a session actually produces — “about to run git push with an earlier CI run unread”, not “when pacing actions”."
|
||
/>
|
||
</label>
|
||
<p v-if="!whenToApply.trim()" class="trigger-warning">
|
||
<strong>Without this, the rule will never reach a session.</strong>
|
||
Nothing is preloaded: a rule arrives when what someone is doing matches
|
||
its trigger, so an empty trigger leaves the rule findable by nobody.
|
||
</p>
|
||
|
||
<fieldset v-if="canon.catalog.length" class="areas">
|
||
<legend>Areas this rule is about</legend>
|
||
<label v-for="entry in canon.catalog" :key="entry.id" class="area-opt">
|
||
<input
|
||
type="checkbox"
|
||
:checked="systemIds.includes(entry.id)"
|
||
@change="toggleSystem(entry.id)"
|
||
/>
|
||
<span>{{ entry.name }}</span>
|
||
</label>
|
||
<p class="field-note">
|
||
What lets this rule reach a project working in that area.
|
||
</p>
|
||
</fieldset>
|
||
|
||
<fieldset class="check">
|
||
<legend>Can this rule go stale?</legend>
|
||
<p class="field-note intro">
|
||
Most rules are <em>decisions</em> — they have no truth value and change only when you
|
||
change them. Leave this empty for those. Fill it in when the rule asserts a
|
||
<em>fact</em> about something outside your control, because those go false quietly.
|
||
</p>
|
||
<label>
|
||
How to check it is still true
|
||
<textarea
|
||
v-model="verifyWith"
|
||
rows="2"
|
||
placeholder="A command, a path, a query — something runnable beats prose."
|
||
/>
|
||
</label>
|
||
<label>
|
||
What would end it
|
||
<textarea
|
||
v-model="expiresWhen"
|
||
rows="2"
|
||
placeholder="A state, not a date — “when the runner can be given a bash shell”."
|
||
/>
|
||
</label>
|
||
|
||
<div v-if="savedCheck" class="stamp">
|
||
<span class="stamp-age" :class="{ unchecked: !verifiedAt }">{{ stampLabel }}</span>
|
||
<span class="stamp-actions">
|
||
<button type="button" :disabled="verifying" @click="verify(true)">Still true</button>
|
||
<button type="button" :disabled="verifying" @click="verify(false)">No longer true</button>
|
||
</span>
|
||
</div>
|
||
<p v-if="savedCheck" class="field-note">
|
||
Record this after actually running the check, never on the strength of the rule
|
||
sounding plausible. “No longer true” deliberately stores nothing — the rule is wrong,
|
||
not in a state worth recording, so it stays at the top of the sweep until you fix or
|
||
retire it.
|
||
</p>
|
||
</fieldset>
|
||
|
||
<RuleHomePicker
|
||
v-if="!isCreating && ruleId !== null && store.currentRule"
|
||
:rule-id="ruleId"
|
||
:topic-id="store.currentRule.topic_id"
|
||
:project-id="store.currentRule.project_id"
|
||
@moved="onMoved"
|
||
/>
|
||
|
||
<section v-if="relations.length" class="relations">
|
||
<h3>Related rules</h3>
|
||
<ul>
|
||
<li v-for="rel in relations" :key="rel.id" class="relation">
|
||
<span class="relation-kind">{{ relationLabel(rel.kind, rel.direction) }}</span>
|
||
<span class="relation-target">rule #{{ rel.rule_id }}</span>
|
||
<span v-if="rel.note" class="relation-note">{{ rel.note }}</span>
|
||
</li>
|
||
</ul>
|
||
<p class="field-note">
|
||
Rules that <em>fail together</em> are linked, never merged — a merged rule cannot be
|
||
cited or surfaced a clause at a time.
|
||
</p>
|
||
</section>
|
||
|
||
<label>
|
||
Why
|
||
<textarea v-model="why" rows="4" placeholder="Rationale — the reason this rule exists." />
|
||
</label>
|
||
<label>
|
||
How to apply
|
||
<textarea v-model="howToApply" rows="4" placeholder="When / where this kicks in." />
|
||
</label>
|
||
|
||
<!-- Only on an existing rule: a rule being created has no past, and an
|
||
"Edit history — none" line on a blank form reads as a broken panel.
|
||
Keyed on ruleId so switching rules reloads rather than showing the
|
||
previous rule's history under the new one's text. -->
|
||
<RuleHistoryPanel
|
||
v-if="!isCreating && ruleId !== null"
|
||
:key="ruleId"
|
||
:rule-id="ruleId"
|
||
:current="store.currentRule"
|
||
/>
|
||
</aside>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.backdrop {
|
||
position: fixed; inset: 0;
|
||
background: rgba(0, 0, 0, 0.4);
|
||
z-index: 100;
|
||
}
|
||
.slide-over {
|
||
position: fixed; top: 0; right: 0; bottom: 0;
|
||
width: min(520px, 90vw);
|
||
background: var(--fs-surface-hover);
|
||
border-left: 2px solid var(--fs-accent);
|
||
padding: 1.5rem;
|
||
overflow-y: auto;
|
||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
|
||
}
|
||
header { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||
header h2 {
|
||
flex: 1; margin: 0;
|
||
font-family: Fraunces, serif; font-style: italic;
|
||
}
|
||
label { display: block; margin-bottom: 1rem; }
|
||
.required { color: var(--fs-accent); }
|
||
input, textarea {
|
||
width: 100%; margin-top: 0.25rem;
|
||
background: var(--fs-surface-page); color: inherit;
|
||
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||
padding: 0.5rem; font: inherit;
|
||
font-family: inherit;
|
||
}
|
||
fieldset { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: 0.75rem; margin-bottom: 1rem; }
|
||
legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||
.area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
|
||
.area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||
.trigger-missing textarea { border-color: var(--fs-warning); }
|
||
/* --fs-warning-fg, not --fs-warning: the token set draws the distinction
|
||
between the warning HUE and warning text, and this is text. */
|
||
.trigger-warning {
|
||
margin: -0.35rem 0 0.6rem; font-size: 0.78rem; line-height: 1.45;
|
||
color: var(--fs-warning-fg);
|
||
}
|
||
.field-note { margin: 0.5rem 0 0; font-size: 0.78rem; color: var(--fs-text-tertiary); line-height: 1.45; }
|
||
|
||
.relations h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
|
||
.relations ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
||
.relation { display: flex; align-items: baseline; gap: 0.4rem; flex-wrap: wrap; font-size: 0.85rem; }
|
||
.relation-kind { color: var(--fs-accent); }
|
||
.relation-target { color: var(--fs-text-primary); }
|
||
.relation-note { width: 100%; font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
||
|
||
/* A real base rule, not just descendants: the dangling-style check reads a
|
||
class that only ever appears as an ancestor as a half-deleted rule, and it
|
||
is right to — an element whose appearance comes only from its tag is one
|
||
`fieldset {}` edit away from being unstyled. */
|
||
.check { margin-bottom: 1rem; }
|
||
.check .intro { margin-top: 0; margin-bottom: 0.75rem; }
|
||
.check label { margin-bottom: 0.75rem; }
|
||
.stamp {
|
||
display: flex; align-items: center; gap: var(--fs-space-2);
|
||
flex-wrap: wrap;
|
||
margin-top: 0.25rem;
|
||
}
|
||
.stamp-age { font-size: 0.8rem; color: var(--fs-text-secondary); font-variant-numeric: tabular-nums; }
|
||
/* Never-checked is INFORMATION, not an error: it is the ordinary starting
|
||
state of every constraint anyone has just written. --fs-overdue (error red)
|
||
is reserved for a broken promise like a missed due date; a verification age
|
||
is not one, and colouring it that way would make a brand-new rule look
|
||
broken. Secondary text, weighted normally. */
|
||
.stamp-age.unchecked { color: var(--fs-text-tertiary); font-style: italic; }
|
||
.stamp-actions { display: flex; gap: var(--fs-space-2); margin-left: auto; }
|
||
.stamp-actions button {
|
||
cursor: pointer; font: inherit; font-size: 0.78rem;
|
||
background: var(--fs-surface-raised); color: var(--fs-text-primary);
|
||
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-sm);
|
||
padding: 0.2rem 0.55rem;
|
||
}
|
||
.stamp-actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
|
||
.stamp-actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
|
||
|
||
.trash, .close { background: none; border: none; cursor: pointer; opacity: 0.6; font-size: 1.25em; }
|
||
.trash:hover, .close:hover { opacity: 1; }
|
||
</style>
|