feat(rules): preferences are writable, and their drift arrives (#3895)
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
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
This commit is contained in:
@@ -39,12 +39,27 @@ export interface RulebookTopic {
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What kind of instruction this is, and it is about FORCE, not importance.
|
||||
*
|
||||
* A `rule` must be FOLLOWED — ignoring it breaks something. It is the
|
||||
* operator's decision, so it changes when they change it. A `preference` is
|
||||
* how they want work DONE — ignoring it costs consistency, not correctness —
|
||||
* and the agent rewrites it in the ordinary course of working, which is what
|
||||
* makes the drift surface necessary.
|
||||
*
|
||||
* Always sent by the server, never inferred from an absent key: "no kind
|
||||
* field" and "kind is rule" must not be the same payload.
|
||||
*/
|
||||
export type RuleKind = "rule" | "preference";
|
||||
|
||||
export interface Rule {
|
||||
id: number;
|
||||
topic_id: number | null;
|
||||
project_id: number | null;
|
||||
title: string;
|
||||
statement: string;
|
||||
kind: RuleKind;
|
||||
/** WHEN this rule fires — the trigger, not the instruction. */
|
||||
when_to_apply: string;
|
||||
why: string;
|
||||
@@ -79,6 +94,8 @@ export interface RuleHeader {
|
||||
title: string;
|
||||
statement: string;
|
||||
topic_id: number | null;
|
||||
/** Unconditional on the wire (services.rulebooks.rule_brief). */
|
||||
kind: RuleKind;
|
||||
/** A date (YYYY-MM-DD), not a timestamp. */
|
||||
updated_at: string | null;
|
||||
when_to_apply?: string;
|
||||
@@ -180,6 +197,9 @@ export async function getRule(id: number): Promise<Rule> {
|
||||
export interface RuleWrite {
|
||||
title: string;
|
||||
statement: string;
|
||||
/** Writable from the editor: a preference is not a lesser rule, it is a
|
||||
* different force, and the person writing it is the one who knows which. */
|
||||
kind: RuleKind;
|
||||
when_to_apply: string;
|
||||
why: string;
|
||||
how_to_apply: string;
|
||||
@@ -256,9 +276,56 @@ export async function getRuleVersion(
|
||||
return apiGet<RuleVersion>(`/api/rules/${ruleId}/versions/${versionId}`);
|
||||
}
|
||||
|
||||
// No restoreRuleVersion, deliberately (milestone 323). Putting an old wording
|
||||
// No restore FOR A RULE, 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.
|
||||
//
|
||||
// A preference is the exception and the server refuses anything else (409).
|
||||
// 323's reasoning is that a rewrite is the operator's own decision; a
|
||||
// preference's rewrite is the agent's, made mid-work without asking, so
|
||||
// putting it back is a veto rather than an undo — and a veto that costs more
|
||||
// than shrugging is not really supervision. Nothing is erased either way:
|
||||
// the restore snapshots too, so the history GAINS the revert.
|
||||
export async function restoreRuleVersion(
|
||||
ruleId: number, versionId: number,
|
||||
): Promise<Rule> {
|
||||
return apiPost<Rule>(`/api/rules/${ruleId}/versions/${versionId}/restore`, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* One preference that has been rewritten: what it said, what it says now, and
|
||||
* what taught the change.
|
||||
*
|
||||
* Both texts ride along so the list can show the diff without a follow-up call
|
||||
* per row — a listing that needs N round-trips to say what it means is one
|
||||
* nobody scrolls, which would leave the drift as unsupervised as before.
|
||||
*/
|
||||
export interface PreferenceDrift {
|
||||
rule: RuleHeader;
|
||||
/** What it said BEFORE the latest rewrite. */
|
||||
previous: {
|
||||
id: number;
|
||||
created_at: string | null;
|
||||
title: string;
|
||||
statement: string;
|
||||
when_to_apply: string;
|
||||
};
|
||||
current: { title: string; statement: string; when_to_apply: string };
|
||||
/**
|
||||
* The record named by `arose_from_id` — what the change was learned from.
|
||||
* Absent when the preference carries none, which is every one written
|
||||
* before that field was required.
|
||||
*/
|
||||
taught_by?: { id: number; title: string };
|
||||
}
|
||||
|
||||
export async function listPreferenceDrift(
|
||||
limit?: number,
|
||||
): Promise<PreferenceDrift[]> {
|
||||
const qs = limit ? `?limit=${limit}` : "";
|
||||
const data = await apiGet<{ drift: PreferenceDrift[] }>(`/api/rules/drift${qs}`);
|
||||
return data.drift;
|
||||
}
|
||||
|
||||
export async function deleteRule(id: number): Promise<void> {
|
||||
return apiDelete(`/api/rules/${id}`);
|
||||
|
||||
@@ -40,3 +40,16 @@
|
||||
padding: 0.05rem 0.4rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* A PREFERENCE, marked because force is the one thing a list of instructions
|
||||
must not leave the reader to infer. A preference does not bind — ignoring it
|
||||
costs consistency, not correctness — and it is the one kind the agent
|
||||
rewrites on its own, so a row that renders identically to a rule teaches the
|
||||
opposite of both facts.
|
||||
|
||||
The accent, not the warning colour: nothing is wrong with a preference. It
|
||||
is a different KIND, and the marker says which. */
|
||||
.rule-chip-preference {
|
||||
color: var(--fs-accent);
|
||||
background: var(--fs-accent-soft);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* What Scribe has changed about how it works with you.
|
||||
*
|
||||
* THE RISK THIS EXISTS FOR (milestone 399). A preference is the one record
|
||||
* kind the agent rewrites on its own, mid-work, without asking — which is
|
||||
* what keeps it current and what makes it dangerous. An agent misreads one
|
||||
* session, rewrites a preference, and follows the rewritten version forever
|
||||
* while the operator never sees the moment it changed. That is worse than
|
||||
* having no preference at all: a confident wrong answer wearing the
|
||||
* operator's own authority.
|
||||
*
|
||||
* `rule_versions` already recorded every rewrite. What it could not do is
|
||||
* ARRIVE. A history you open one rule at a time, having first suspected that
|
||||
* rule, is not oversight — so this pane is the PUSH half, and it sits beside
|
||||
* the staleness sweep for the same reason that does: drift belongs to no one
|
||||
* rulebook.
|
||||
*
|
||||
* Cross-cutting, and deliberately not a filter on the per-topic rule list —
|
||||
* that list shows one topic of one rulebook, so filtering it would silently
|
||||
* under-report, which is the exact failure this surface exists to catch.
|
||||
*/
|
||||
import { onMounted, ref } from "vue";
|
||||
import DiffView from "@/components/DiffView.vue";
|
||||
import { computeDiff } from "@/utils/diff";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type { PreferenceDrift } from "@/api/rulebooks";
|
||||
|
||||
const emit = defineEmits<{ "open-rule": [id: number] }>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const toast = useToastStore();
|
||||
const openId = ref<number | null>(null);
|
||||
const busyId = ref<number | null>(null);
|
||||
|
||||
/** Old on the left, new on the right — the direction a reader expects of
|
||||
* "what changed", and the opposite of the rule history panel, which is
|
||||
* answering "what did it used to say" from the current text backwards. */
|
||||
function diffFor(row: PreferenceDrift) {
|
||||
return computeDiff(row.previous.statement, row.current.statement);
|
||||
}
|
||||
|
||||
function triggerChanged(row: PreferenceDrift): boolean {
|
||||
return row.previous.when_to_apply !== row.current.when_to_apply;
|
||||
}
|
||||
|
||||
function stamp(iso: string | null): string {
|
||||
return iso ? iso.slice(0, 10) : "";
|
||||
}
|
||||
|
||||
function toggle(row: PreferenceDrift) {
|
||||
openId.value = openId.value === row.rule.id ? null : row.rule.id;
|
||||
}
|
||||
|
||||
/** The veto. One action, because a veto that costs more than shrugging is
|
||||
* not really supervision — and nothing is lost either way: the restore is
|
||||
* itself an edit, so the rewrite stays in the preference's history with the
|
||||
* revert recorded after it. */
|
||||
async function restore(row: PreferenceDrift) {
|
||||
busyId.value = row.rule.id;
|
||||
try {
|
||||
await store.restoreVersion(row.rule.id, row.previous.id);
|
||||
toast.show(`Put “${row.previous.title}” back`, "success");
|
||||
openId.value = null;
|
||||
} catch {
|
||||
toast.show("Could not put that wording back", "error");
|
||||
} finally {
|
||||
busyId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => store.fetchDrift());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pane drift">
|
||||
<header>
|
||||
<h2>Recent changes</h2>
|
||||
<p class="lede">
|
||||
Preferences Scribe rewrote while working, most recently changed first. A
|
||||
preference is how you want work done, so sessions keep it current
|
||||
without asking — this is where you see what they decided. Rules are not
|
||||
here: those change when you change them.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p v-if="store.loading" class="state">Loading…</p>
|
||||
|
||||
<!-- Nothing changed is the ordinary state and must not read as a fault. -->
|
||||
<p v-else-if="!store.drift.length" class="state empty">
|
||||
Nothing has been rewritten. A preference appears here the first time a
|
||||
session changes one — until then there is nothing to review.
|
||||
</p>
|
||||
|
||||
<ol v-else class="rows">
|
||||
<li v-for="row in store.drift" :key="row.rule.id" class="row">
|
||||
<div class="row-head">
|
||||
<button class="row-title" @click="emit('open-rule', row.rule.id)">
|
||||
{{ row.rule.title }}
|
||||
</button>
|
||||
<span class="when">{{ stamp(row.previous.created_at) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- The provenance, named rather than numbered: a bare id reads as
|
||||
complete to the writer and as homework to the reader. -->
|
||||
<p v-if="row.taught_by" class="taught">
|
||||
Learned from <em>{{ row.taught_by.title }}</em>
|
||||
<span class="taught-id">#{{ row.taught_by.id }}</span>
|
||||
</p>
|
||||
<p v-else class="taught untaught">
|
||||
Nothing recorded what taught this change.
|
||||
</p>
|
||||
|
||||
<button class="expand" :aria-expanded="openId === row.rule.id" @click="toggle(row)">
|
||||
{{ openId === row.rule.id ? "Hide what changed" : "See what changed" }}
|
||||
</button>
|
||||
|
||||
<div v-if="openId === row.rule.id" class="detail">
|
||||
<p v-if="triggerChanged(row)" class="trigger-moved">
|
||||
Its trigger changed too, so it now arrives at a different moment.
|
||||
<span class="was">Was:</span> {{ row.previous.when_to_apply || "nothing" }}
|
||||
</p>
|
||||
<DiffView v-if="diffFor(row).length" :diff="diffFor(row)" />
|
||||
<p v-else class="state">
|
||||
The statement is unchanged — this edit moved another field.
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button
|
||||
:disabled="busyId === row.rule.id"
|
||||
@click="restore(row)"
|
||||
>Put the old wording back</button>
|
||||
<span class="actions-note">
|
||||
Kept, not erased: this is recorded as another edit, so both
|
||||
wordings stay in the preference's history.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p v-if="store.drift.length" class="footnote">
|
||||
One row per preference, carrying its latest rewrite. A preference changed
|
||||
several times shows the most recent one — its full history is in its
|
||||
editor, under <strong>Edit history</strong>.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style src="@/assets/rules-shared.css" />
|
||||
<style scoped>
|
||||
.drift { display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
.lede {
|
||||
margin: 0; max-width: 62ch; font-size: var(--fs-size-body-sm);
|
||||
color: var(--fs-text-secondary); line-height: var(--fs-leading-body);
|
||||
}
|
||||
|
||||
.state { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
|
||||
.state.empty { color: var(--fs-text-tertiary); }
|
||||
|
||||
.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-3); }
|
||||
.row {
|
||||
background: var(--fs-surface-raised);
|
||||
border-radius: var(--fs-radius-md);
|
||||
padding: var(--fs-space-3);
|
||||
}
|
||||
.row-head { display: flex; align-items: baseline; gap: var(--fs-space-2); flex-wrap: wrap; }
|
||||
.row-title {
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
font-family: Fraunces, serif; font-style: italic; font-size: 1.02rem;
|
||||
color: var(--fs-text-primary); text-align: left;
|
||||
}
|
||||
.row-title:hover { text-decoration: underline; }
|
||||
.when {
|
||||
margin-left: auto; font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-secondary); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.taught {
|
||||
margin: var(--fs-space-2) 0 0; font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-secondary); line-height: var(--fs-leading-body);
|
||||
}
|
||||
.taught em { font-style: italic; color: var(--fs-text-primary); }
|
||||
.taught-id { margin-left: 0.35rem; color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; }
|
||||
/* Not a warning: every preference written before provenance was required has
|
||||
none, and marking those as faults would cry wolf on the whole backlog. */
|
||||
.taught.untaught { color: var(--fs-text-tertiary); font-style: italic; }
|
||||
|
||||
.expand {
|
||||
align-self: flex-start; margin-top: var(--fs-space-2);
|
||||
background: none; border: none; padding: 0; cursor: pointer;
|
||||
font: inherit; font-size: var(--fs-size-tiny); color: var(--fs-text-secondary);
|
||||
}
|
||||
.expand:hover { color: var(--fs-text-primary); text-decoration: underline; }
|
||||
|
||||
.detail { margin-top: var(--fs-space-2); display: flex; flex-direction: column; gap: var(--fs-space-2); }
|
||||
/* A TINT, not the solid token — `--fs-warning-fg` is defined as warning text
|
||||
ON a warning tint, and painting it over solid `--fs-warning` is the
|
||||
same-hue contrast failure #3141 records. */
|
||||
.trigger-moved {
|
||||
margin: 0; font-size: var(--fs-size-tiny); line-height: var(--fs-leading-body);
|
||||
color: var(--fs-warning-fg);
|
||||
background: color-mix(in srgb, var(--fs-warning) 12%, transparent);
|
||||
border-radius: var(--fs-radius-sm); padding: var(--fs-space-2);
|
||||
}
|
||||
.was { color: var(--fs-text-tertiary); }
|
||||
|
||||
.actions { display: flex; align-items: baseline; gap: var(--fs-space-3); flex-wrap: wrap; }
|
||||
.actions button {
|
||||
cursor: pointer; font: inherit; font-size: 0.78rem;
|
||||
background: var(--fs-surface-page); color: var(--fs-text-primary);
|
||||
border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md);
|
||||
padding: 0.25rem 0.6rem;
|
||||
}
|
||||
.actions button:hover:not(:disabled) { background: var(--fs-surface-hover); }
|
||||
.actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
|
||||
.actions-note {
|
||||
flex: 1; min-width: 18ch; font-size: var(--fs-size-tiny);
|
||||
color: var(--fs-text-tertiary); line-height: var(--fs-leading-body);
|
||||
}
|
||||
|
||||
.footnote { margin: 0; max-width: 62ch; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); line-height: var(--fs-leading-body); }
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@ 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";
|
||||
import type { Rule, RuleKind } from "@/api/rulebooks";
|
||||
|
||||
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
@@ -14,6 +14,11 @@ 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("");
|
||||
@@ -70,6 +75,7 @@ async function load() {
|
||||
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 || "";
|
||||
@@ -80,6 +86,7 @@ async function load() {
|
||||
title.value = "";
|
||||
statement.value = "";
|
||||
whenToApply.value = "";
|
||||
kind.value = "rule";
|
||||
systemIds.value = [];
|
||||
why.value = "";
|
||||
howToApply.value = "";
|
||||
@@ -98,6 +105,7 @@ async function save() {
|
||||
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,
|
||||
@@ -140,7 +148,7 @@ watch(() => props.ruleId, load);
|
||||
<div class="backdrop" @click="save">
|
||||
<aside class="slide-over" @click.stop>
|
||||
<header>
|
||||
<h2>{{ isCreating ? "New rule" : "Edit rule" }}</h2>
|
||||
<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>
|
||||
@@ -148,6 +156,34 @@ watch(() => props.ruleId, load);
|
||||
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)." />
|
||||
@@ -267,6 +303,17 @@ watch(() => props.ruleId, load);
|
||||
</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);
|
||||
|
||||
@@ -24,8 +24,18 @@ const emit = defineEmits<{
|
||||
<header><h2>Rules</h2></header>
|
||||
<ul>
|
||||
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
||||
<div class="title">
|
||||
<div class="title" :class="{ 'is-preference': r.kind === 'preference' }">
|
||||
{{ r.title }}
|
||||
<!-- Force is stated, never inferred. A preference does not bind and
|
||||
is the one kind a session rewrites on its own, so an unmarked
|
||||
row would teach the opposite of both. Rules carry no chip:
|
||||
they are the default reading of a rulebook, and marking every
|
||||
row marks nothing. -->
|
||||
<span
|
||||
v-if="r.kind === 'preference'"
|
||||
class="rule-chip rule-chip-preference"
|
||||
title="How you want work done. It does not bind, and Scribe updates it as the work teaches it."
|
||||
>preference</span>
|
||||
<!-- Marked only when something is WRONG: every rule arrives by
|
||||
retrieval now, so "conditional" stopped distinguishing anything.
|
||||
A missing trigger does — it means nothing can retrieve this. -->
|
||||
@@ -70,6 +80,12 @@ li {
|
||||
}
|
||||
li:hover { background: var(--fs-surface-hover); }
|
||||
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
||||
/* The chip says which kind; this says it again at a glance, for scanning a
|
||||
long topic rather than reading one row. Weight, not colour — the chip
|
||||
already carries the accent, and a second coloured thing would compete
|
||||
with it for the same job. */
|
||||
.title.is-preference { font-weight: 500; }
|
||||
|
||||
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
||||
.meta { display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.35rem; font-size: 0.75em; }
|
||||
.trigger { flex: 1; min-width: 0; color: var(--fs-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
@@ -3,8 +3,17 @@ import { ref } from "vue";
|
||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||
import type { Rulebook } from "@/api/rulebooks";
|
||||
|
||||
defineProps<{ rulebooks: Rulebook[]; selectedId: number | null; sweepActive: boolean }>();
|
||||
const emit = defineEmits<{ select: [id: number]; "select-sweep": [] }>();
|
||||
defineProps<{
|
||||
rulebooks: Rulebook[];
|
||||
selectedId: number | null;
|
||||
sweepActive: boolean;
|
||||
driftActive: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
select: [id: number];
|
||||
"select-sweep": [];
|
||||
"select-drift": [];
|
||||
}>();
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const isCreating = ref(false);
|
||||
@@ -44,6 +53,17 @@ async function submitNew() {
|
||||
>
|
||||
Due for verification
|
||||
</button>
|
||||
<!-- The other cross-cutting view, and the one the operator would not think
|
||||
to ask for: a preference is rewritten by the agent rather than by them,
|
||||
so "what changed" has no rulebook to look in and no reason to be
|
||||
suspected in the first place. -->
|
||||
<button
|
||||
class="sweep-entry"
|
||||
:class="{ active: driftActive }"
|
||||
@click="emit('select-drift')"
|
||||
>
|
||||
Recent changes
|
||||
</button>
|
||||
|
||||
<div class="new-rulebook">
|
||||
<button v-if="!isCreating" @click="isCreating = true">+ New rulebook</button>
|
||||
|
||||
@@ -10,6 +10,7 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
const rulesByTopic = ref<Record<number, RuleHeader[]>>({});
|
||||
const currentRule = ref<Rule | null>(null);
|
||||
const rulesDue = ref<api.RuleVerificationRow[]>([]);
|
||||
const drift = ref<api.PreferenceDrift[]>([]);
|
||||
// Kept so a verify re-reads the sweep with the SAME filters the operator is
|
||||
// looking at — re-fetching unfiltered would silently widen the list under
|
||||
// them at the moment they acted on it.
|
||||
@@ -106,6 +107,10 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
title: rule.title,
|
||||
statement: rule.statement,
|
||||
topic_id: rule.topic_id,
|
||||
// Carried, not defaulted: a row written here must render with the same
|
||||
// force as the same row re-fetched, or a preference the operator just
|
||||
// created would sit in the list looking like a rule until a reload.
|
||||
kind: rule.kind,
|
||||
updated_at: rule.updated_at,
|
||||
when_to_apply: rule.when_to_apply || undefined,
|
||||
arose_from_id: rule.arose_from_id ?? undefined,
|
||||
@@ -202,6 +207,46 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* What Scribe has changed about how it works with the operator.
|
||||
*
|
||||
* Preferences only — a rule changes when its author changes it, so
|
||||
* including them would bury the unreviewed rows under the operator's own
|
||||
* edits, which is the failure this surface exists to prevent.
|
||||
*/
|
||||
async function fetchDrift(limit?: number) {
|
||||
loading.value = true;
|
||||
try {
|
||||
drift.value = await api.listPreferenceDrift(limit);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load recent changes", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a preference back to what a version said — the operator's veto.
|
||||
*
|
||||
* The drift list is RE-READ rather than patched, for the reason the sweep
|
||||
* is: this surface is an ORDER (most recently changed first) and the
|
||||
* restore is itself an edit, so the row's place in that order has just
|
||||
* changed. A locally-mutated row would sit in its old position describing
|
||||
* a rewrite that is no longer the latest one.
|
||||
*/
|
||||
async function restoreVersion(ruleId: number, versionId: number) {
|
||||
const rule = await api.restoreRuleVersion(ruleId, versionId);
|
||||
if (currentRule.value?.id === ruleId) currentRule.value = rule;
|
||||
for (const tid of Object.keys(rulesByTopic.value)) {
|
||||
const list = rulesByTopic.value[Number(tid)];
|
||||
const idx = list.findIndex((r) => r.id === ruleId);
|
||||
if (idx >= 0) list[idx] = toHeader(rule);
|
||||
}
|
||||
if (drift.value.length) await fetchDrift();
|
||||
return rule;
|
||||
}
|
||||
|
||||
async function deleteRule(id: number) {
|
||||
await api.deleteRule(id);
|
||||
if (currentRule.value?.id === id) currentRule.value = null;
|
||||
@@ -211,12 +256,13 @@ export const useRulebooksStore = defineStore("rulebooks", () => {
|
||||
}
|
||||
|
||||
return {
|
||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, lastSweepOpts, loading,
|
||||
rulebooks, topicsByRulebook, rulesByTopic, currentRule, rulesDue, drift, lastSweepOpts, loading,
|
||||
placeMovedRule,
|
||||
fetchRulebooks, fetchTopics, fetchRules, fetchRule,
|
||||
createRulebook, updateRulebook, deleteRulebook,
|
||||
createTopic, updateTopic, deleteTopic,
|
||||
createRule, updateRule, deleteRule, relateRules, unrelateRules,
|
||||
fetchRulesDue, verifyRule,
|
||||
fetchDrift, restoreVersion,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import RulebookDetailPane from "@/components/rules/RulebookDetailPane.vue";
|
||||
import RuleListPane from "@/components/rules/RuleListPane.vue";
|
||||
import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue";
|
||||
import RuleSweepPane from "@/components/rules/RuleSweepPane.vue";
|
||||
import PreferenceDriftPane from "@/components/rules/PreferenceDriftPane.vue";
|
||||
|
||||
const store = useRulebooksStore();
|
||||
const route = useRoute();
|
||||
@@ -17,6 +18,7 @@ const selectedTopicId = ref<number | null>(null);
|
||||
const editingRuleId = ref<number | null>(null);
|
||||
const creatingRuleForTopic = ref<number | null>(null);
|
||||
const sweepActive = ref(false);
|
||||
const driftActive = ref(false);
|
||||
|
||||
function syncFromRoute() {
|
||||
const rb = route.query.rb ? Number(route.query.rb) : null;
|
||||
@@ -26,19 +28,23 @@ function syncFromRoute() {
|
||||
selectedTopicId.value = topic;
|
||||
editingRuleId.value = rule;
|
||||
sweepActive.value = route.query.view === "due";
|
||||
driftActive.value = route.query.view === "drift";
|
||||
}
|
||||
|
||||
function selectSweep() {
|
||||
sweepActive.value = true;
|
||||
// Keeps ?rule=… so the editor survives the mode switch, and drops the
|
||||
// rulebook/topic selection the sweep does not use.
|
||||
/** The two cross-cutting views share one `view` query key, so entering either
|
||||
* leaves the other — and keeps `?rule=…` so an open editor survives the
|
||||
* switch, the way the sweep already did. */
|
||||
function selectCrossCutting(view: "due" | "drift") {
|
||||
sweepActive.value = view === "due";
|
||||
driftActive.value = view === "drift";
|
||||
const { rb, topic, ...rest } = route.query;
|
||||
void rb; void topic;
|
||||
router.replace({ query: { ...rest, view: "due" } });
|
||||
router.replace({ query: { ...rest, view } });
|
||||
}
|
||||
|
||||
function selectRulebook(id: number) {
|
||||
sweepActive.value = false;
|
||||
driftActive.value = false;
|
||||
selectedRulebookId.value = id;
|
||||
selectedTopicId.value = null;
|
||||
router.replace({ query: { rb: String(id) } });
|
||||
@@ -84,10 +90,13 @@ watch(() => route.query, syncFromRoute);
|
||||
:rulebooks="store.rulebooks"
|
||||
:selected-id="selectedRulebookId"
|
||||
:sweep-active="sweepActive"
|
||||
:drift-active="driftActive"
|
||||
@select="selectRulebook"
|
||||
@select-sweep="selectSweep"
|
||||
@select-sweep="selectCrossCutting('due')"
|
||||
@select-drift="selectCrossCutting('drift')"
|
||||
/>
|
||||
<RuleSweepPane v-if="sweepActive" class="sweep-span" @open-rule="openRule" />
|
||||
<PreferenceDriftPane v-else-if="driftActive" class="sweep-span" @open-rule="openRule" />
|
||||
<RulebookDetailPane
|
||||
v-else-if="selectedRulebookId !== null"
|
||||
:rulebook-id="selectedRulebookId"
|
||||
@@ -99,13 +108,13 @@ watch(() => route.query, syncFromRoute);
|
||||
<p>Select a rulebook to view its topics.</p>
|
||||
</div>
|
||||
<RuleListPane
|
||||
v-if="!sweepActive && selectedTopicId !== null"
|
||||
v-if="!sweepActive && !driftActive && selectedTopicId !== null"
|
||||
:topic-id="selectedTopicId"
|
||||
:rules="store.rulesByTopic[selectedTopicId] || []"
|
||||
@open-rule="openRule"
|
||||
@create-rule="startCreatingRule"
|
||||
/>
|
||||
<div v-else-if="!sweepActive" class="pane empty">
|
||||
<div v-else-if="!sweepActive && !driftActive" class="pane empty">
|
||||
<p>Select a topic to view its rules.</p>
|
||||
</div>
|
||||
<RuleEditorSlideOver
|
||||
@@ -125,8 +134,9 @@ watch(() => route.query, syncFromRoute);
|
||||
gap: 1px;
|
||||
background: var(--fs-border-color);
|
||||
}
|
||||
/* The sweep is cross-cutting, so it takes the width the rulebook + topic
|
||||
panes would have used rather than being squeezed into one column. */
|
||||
/* A cross-cutting pane — the sweep, or recent changes — takes the width the
|
||||
rulebook + topic panes would have used rather than being squeezed into one
|
||||
column. */
|
||||
.sweep-span { grid-column: 2 / -1; }
|
||||
.pane.empty {
|
||||
background: var(--fs-surface-hover);
|
||||
|
||||
Reference in New Issue
Block a user