CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 11s
CI & Build / integration (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m32s
CI & Build / Build & push image (push) Successful in 34s
Milestone 399 step 5. Steps 1-4 put preferences into the backend: a kind column, an inverted write path, a third register in the injected block, a delivery slot. Nothing the operator could touch. Rule 27 forbids leaving it there, and here it matters more than usual, because the UI is the only guard against the risk the milestone named up front — an agent misreads one session, rewrites a preference, and follows the rewritten version forever while the operator never sees the moment it changed. Four things ship. A preference is DISTINGUISHABLE. `kind` reaches the client (the server has always sent it in rule_brief) and a preference carries a chip. Force is the one thing a list of instructions must not leave the reader to infer, and a row that renders identically to a rule teaches the opposite of both facts about a preference: it does not bind, and a session may rewrite it. A preference is WRITABLE. The editor gains the kind as a first-class choice with the test beside it — what happens when someone does not do this — and says plainly, when preference is chosen, that sessions rewrite these without asking and every rewrite is kept. DRIFT ARRIVES. `GET /api/rules/drift` returns one row per rewritten preference carrying its latest rewrite: what it said, what it says now, and the record named by `arose_from_id` that taught the change. Both texts ride along so the list shows the diff without a call per row. The new pane sits beside the staleness sweep, because drift belongs to no one rulebook, and it answers a question the operator would not have thought to ask. REVERSION IS ONE ACTION, and this is the carve-out worth arguing with. Milestone 323 refused a one-click restore for rules — "a binding instruction should not be revertible in one click", because a silent revert erases the only record of why the rewrite happened. That reasoning turns on the rewrite being the operator's own decision. A preference's is not: the agent makes it mid-work without asking, so reverting is a veto over someone else's edit rather than an undo of your own, and a veto costing more than a shrug is not supervision. The route refuses anything but a preference (409), and nothing is erased: the restore goes through update_rule, so it snapshots too and the history GAINS the revert. An integration test pins that, because it is the whole basis for the exception. Tested against real Postgres — every claim is about which rows come back and in what order, which a stand-in session cannot judge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
397 lines
16 KiB
Vue
397 lines
16 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, RuleKind } 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("");
|
||
// Defaults to `rule`, matching the server's column default. The safe
|
||
// direction is the one that binds: a preference mislabelled as a rule is
|
||
// followed too faithfully, where a rule mislabelled as a preference is one a
|
||
// session may quietly rewrite.
|
||
const kind = ref<RuleKind>("rule");
|
||
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 || "";
|
||
kind.value = r.kind;
|
||
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 = "";
|
||
kind.value = "rule";
|
||
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,
|
||
kind: kind.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" : "Edit"} ${kind === "preference" ? "preference" : "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>
|
||
<fieldset class="kind">
|
||
<legend>What kind of instruction is this?</legend>
|
||
<label class="kind-opt">
|
||
<input v-model="kind" type="radio" value="rule" />
|
||
<span>
|
||
<strong>Rule</strong> — must be followed. Ignoring it breaks
|
||
something or crosses a boundary. It changes when you change it.
|
||
</span>
|
||
</label>
|
||
<label class="kind-opt">
|
||
<input v-model="kind" type="radio" value="preference" />
|
||
<span>
|
||
<strong>Preference</strong> — how you want work done. Ignoring it
|
||
costs consistency, not correctness, and <em>Scribe updates it as
|
||
the work teaches it</em>.
|
||
</span>
|
||
</label>
|
||
<p class="field-note">
|
||
The test is what happens when someone does not do it. Something
|
||
breaks — a rule. The tenth time goes differently from the ninth — a
|
||
preference.
|
||
</p>
|
||
</fieldset>
|
||
<p v-if="kind === 'preference'" class="kind-drift">
|
||
Sessions rewrite preferences without asking, which is what makes them
|
||
stay current. Every rewrite is kept, and
|
||
<strong>Recent changes</strong> lists them with what taught each one.
|
||
</p>
|
||
<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>
|
||
.kind { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: var(--fs-space-3); margin: var(--fs-space-3) 0; }
|
||
.kind legend { font-size: var(--fs-size-tiny); text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny); color: var(--fs-text-tertiary); padding: 0 var(--fs-space-2); }
|
||
.kind-opt { display: flex; align-items: flex-start; gap: var(--fs-space-2); margin-bottom: var(--fs-space-2); font-size: var(--fs-size-body-sm); line-height: var(--fs-leading-body); }
|
||
.kind-opt input { margin-top: 0.2rem; accent-color: var(--fs-accent); flex: none; }
|
||
.kind-drift {
|
||
margin: 0 0 var(--fs-space-3); padding: var(--fs-space-2);
|
||
font-size: var(--fs-size-tiny); line-height: var(--fs-leading-body);
|
||
color: var(--fs-text-secondary);
|
||
background: var(--fs-accent-soft); border-radius: var(--fs-radius-sm);
|
||
}
|
||
|
||
.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>
|