feat(rules): the check is editable, visible, and sweepable in the UI (#3098, milestone 312 step 4)
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m13s
CI & Build / Build & push image (push) Successful in 37s
CI & Build / Python lint (push) Successful in 5s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 1m13s
CI & Build / Build & push image (push) Successful in 37s
Rule 27 — the milestone was backend-only until this. Four surfaces:
RULE EDITOR — verify_with and expires_when under a legend that asks the
actual question ("Can this rule go stale?") and says empty is the normal
answer, because most rules are decisions and a form that implies a missing
field would get them filled in out of tidiness. When the SAVED rule carries
a check, the stamp shows with Still true / No longer true beside it. The
stamp reads the stored value, not the draft: an unsaved edit to the textarea
has not been run against anything.
SWEEP PANE — its own surface, not a filter on the rule list. That list can
only ever show one topic of one rulebook, and a rule that has gone false
belongs to no one rulebook; filtering it would under-report, which is the
failure this whole surface exists to catch. Reached from the rulebook list,
below the rulebooks, because that is where you go to look at rules.
RULE ROWS — a chip only on rules carrying a check, so its presence is the
signal. PROJECT RULES TAB — the check shows beside `why` when a rule has
one, read-only: that tab is the project's view of what binds it.
NO AGE-GRADED COLOUR anywhere, deliberately. 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. --fs-overdue is error red and reserved
for a broken promise like a missed due date; a verification age is not one,
and colouring it that way makes a rule someone just wrote look broken. Only
"never" is marked, because it is categorically different from a date rather
than a worse one — and it is marked by weight, not hue.
An empty sweep says "Nothing to check", not nothing: good news must not read
as a broken page.
Two chips (tier, then verification) turned out byte-identical, so .rule-chip
moves to rules-shared.css and snippet #2906 is updated to match rather than
left describing a file that has moved on. Its header comment counted the
panes it served; that count went stale the moment a fourth arrived, so it no
longer counts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,8 @@ const tier = ref<RuleTier>("always_on");
|
||||
const systemIds = ref<number[]>([]);
|
||||
const why = ref("");
|
||||
const howToApply = ref("");
|
||||
const verifyWith = ref("");
|
||||
const expiresWhen = ref("");
|
||||
|
||||
const relations = computed(() => store.currentRule?.relations ?? []);
|
||||
|
||||
@@ -38,6 +40,27 @@ function toggleSystem(id: number) {
|
||||
|
||||
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);
|
||||
@@ -50,6 +73,8 @@ async function load() {
|
||||
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 = "";
|
||||
@@ -59,6 +84,8 @@ async function load() {
|
||||
systemIds.value = [];
|
||||
why.value = "";
|
||||
howToApply.value = "";
|
||||
verifyWith.value = "";
|
||||
expiresWhen.value = "";
|
||||
}
|
||||
await canon.fetchCatalog();
|
||||
}
|
||||
@@ -78,6 +105,11 @@ async function save() {
|
||||
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);
|
||||
@@ -162,6 +194,45 @@ watch(() => props.ruleId, load);
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="check">
|
||||
<legend>Can this rule go stale?</legend>
|
||||
<p class="tier-test 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="tier-test">
|
||||
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>
|
||||
|
||||
<section v-if="relations.length" class="relations">
|
||||
<h3>Related rules</h3>
|
||||
<ul>
|
||||
@@ -231,6 +302,35 @@ legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary);
|
||||
.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>
|
||||
|
||||
Reference in New Issue
Block a user