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
103 lines
5.0 KiB
Vue
103 lines
5.0 KiB
Vue
<script setup lang="ts">
|
|
import type { RuleHeader } from "@/api/rulebooks";
|
|
import UsageBadge from "@/components/UsageBadge.vue";
|
|
|
|
/** The dead-weight nudge for a RULE — two remedies, not one, which is the
|
|
* whole reason this advice is per-kind. A snippet nobody opens should
|
|
* probably go. A rule nobody opens may be perfectly good and simply firing on
|
|
* the wrong thing, so "delete it" would be the wrong nudge half the time and
|
|
* the operator has to be the one who picks. */
|
|
const RULE_DEAD_WEIGHT =
|
|
"Kept arriving without being read. Either its trigger fires on the wrong " +
|
|
"work — reword “when to apply” so it says when — or it is not wanted here. " +
|
|
"Until one or the other, it takes a slot in every write it matches.";
|
|
|
|
defineProps<{ topicId: number; rules: RuleHeader[] }>();
|
|
const emit = defineEmits<{
|
|
"open-rule": [id: number];
|
|
"create-rule": [topicId: number];
|
|
}>();
|
|
</script>
|
|
|
|
<template>
|
|
<section class="pane">
|
|
<header><h2>Rules</h2></header>
|
|
<ul>
|
|
<li v-for="r in rules" :key="r.id" @click="emit('open-rule', r.id)">
|
|
<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. -->
|
|
<span
|
|
v-if="!r.when_to_apply"
|
|
class="rule-chip rule-chip-inert"
|
|
title="No trigger, so nothing can retrieve it — this rule will never reach a session"
|
|
>never surfaces</span>
|
|
<!-- Present only on a rule carrying a check, so the chip's very
|
|
presence says "this one asserts a fact that can go false". -->
|
|
<span
|
|
v-if="r.last_verified"
|
|
class="rule-chip check-chip"
|
|
:class="{ unchecked: r.last_verified === 'never' }"
|
|
:title="r.last_verified === 'never'
|
|
? 'Asserts a fact nobody has confirmed yet'
|
|
: `Check last passed ${r.last_verified}`"
|
|
>{{ r.last_verified === "never" ? "unverified" : `checked ${r.last_verified}` }}</span>
|
|
<UsageBadge :usage="r.usage" :dead-weight-advice="RULE_DEAD_WEIGHT" />
|
|
</div>
|
|
<div class="statement">{{ r.statement }}</div>
|
|
<div v-if="r.when_to_apply || r.updated_at" class="meta">
|
|
<span v-if="r.when_to_apply" class="trigger">{{ r.when_to_apply }}</span>
|
|
<span v-if="r.updated_at" class="age" :title="`Last changed ${r.updated_at}`">{{ r.updated_at }}</span>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
<button class="new-rule" @click="emit('create-rule', topicId)">+ New rule</button>
|
|
</section>
|
|
</template>
|
|
|
|
<style src="@/assets/rules-shared.css" />
|
|
<style scoped>
|
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
|
li {
|
|
padding: 0.75rem;
|
|
cursor: pointer;
|
|
border-radius: 6px;
|
|
border-left: 2px solid var(--fs-accent);
|
|
margin-bottom: 0.5rem;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
}
|
|
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; }
|
|
.age { color: var(--fs-text-tertiary); font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
|
/* Only the departures from .rule-chip (rules-shared.css) live here. */
|
|
.check-chip { font-variant-numeric: tabular-nums; }
|
|
/* No age-graded colour on purpose. The sweep is already ordered by urgency, so
|
|
a red/amber ramp would restate the ordering AND require an invented "stale
|
|
after N days" threshold — a magic number nobody could defend and the first
|
|
thing to go out of date. Only "never" is marked, because it is categorically
|
|
different from a date rather than a worse one. */
|
|
.check-chip.unchecked { font-style: italic; color: var(--fs-text-tertiary); }
|
|
.new-rule { cursor: pointer; }
|
|
</style>
|