Files
FabledScribe/frontend/src/components/MomentProposals.vue
T
bvandeusenandClaude Opus 5.5 4e1320120d
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 2m3s
CI & Build / Build & push image (push) Successful in 18s
feat(moments): a mount that keeps arriving where it does not apply proposes its own removal (milestone 458 step 7b, #4955)
The open-after-moment signal proposes a mount; nothing proposed taking one
off, so a wrong mount was noise at every occurrence until someone happened
to notice. rule_misfired(rule_id, moment, why, reached_by) records a report
against a MOUNTED pair, counted per distinct day (the MCP door carries no
session id) on a new rule_moment_judgments.misfire column (migration 0119,
backup v24). At three days the response carries a line asking the agent to
offer the operator the fix - reject takes the rule off, unmap_action stops
the action reaching the moment, confirm keeps the mount and stops the
asking - and Settings > Moments lists it as an unmount proposal with the
reasons and the actions that reached it. A re-mount clears the count.

Taught in moments.md, missed-retrieval.md and the reply hold's wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 18:19:59 -04:00

191 lines
7.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { getProposals, judgeProposals } from "@/api/moments";
import type { MomentProposal, ProposalsPayload, RuleProposals, Verdict } from "@/api/moments";
import { apiErrorMessage } from "@/api/client";
import { useMomentsStore } from "@/stores/moments";
import { useToastStore } from "@/stores/toast";
/**
* Proposals about a rule's moments, waiting on a person. A mount proposal
* (milestone 458 step 7) comes from a pass that read the rule, or from the
* rule being opened just after the moment fired in several sessions; nothing
* is mounted until "Mount" is pressed. An unmount proposal (step 7b) comes
* from sessions reporting that the mount arrived where it did not apply;
* nothing is taken off until "Take it off" is pressed. Both are the judgment
* `judge_rule_moments` makes in a session, through the same service.
*/
const store = useMomentsStore();
const toast = useToastStore();
const data = ref<ProposalsPayload | null>(null);
const failed = ref(false);
const busy = ref<string | null>(null);
const rules = computed<RuleProposals[]>(() => data.value?.rules ?? []);
async function load() {
try {
data.value = await getProposals();
failed.value = false;
} catch {
failed.value = true;
}
}
function key(rule: RuleProposals, p: MomentProposal): string {
return `${rule.id}:${p.moment}`;
}
function plural(n: number, one: string, many: string): string {
return `${n} ${n === 1 ? one : many}`;
}
function sourceLabel(p: MomentProposal): string {
if (p.proposal === "unmount") {
return `reported as not applying here on ${plural(p.evidence.situations, "day", "days")}`;
}
if (p.source === "signal") {
return `opened just after this moment in ${p.evidence.situations} sessions`;
}
return "proposed from the rule's text";
}
// What the action that reached the moment was, most frequent first — so the
// operator can see whether it is the rule or one action that keeps misfiring.
function actions(p: MomentProposal): string[] {
return Object.entries(p.reached_by ?? {})
.sort((a, b) => b[1] - a[1])
.map(([action, n]) => `${action} ×${n}`);
}
function note(p: MomentProposal, verdict: Verdict): string {
if (p.proposal === "unmount") {
return verdict === "confirm" ? "Kept in Settings." : "Taken off in Settings.";
}
return verdict === "confirm" ? "Mounted in Settings." : "Declined in Settings.";
}
async function decide(rule: RuleProposals, p: MomentProposal, verdict: Verdict) {
busy.value = key(rule, p);
try {
const out = await judgeProposals([{
rule_id: rule.id, moment: p.moment, verdict, note: note(p, verdict),
}]);
if (out.refused.length) {
toast.show(out.refused[0].error, "error");
return;
}
await load();
// A mount or an unmount changes the per-moment counts the list below shows.
if ((p.proposal === "unmount") === (verdict === "reject")) await store.load(true);
} catch (e) {
toast.show(apiErrorMessage(e, "Could not record that"), "error");
} finally {
busy.value = null;
}
}
onMounted(load);
</script>
<template>
<section v-if="failed || rules.length" class="proposals" aria-labelledby="proposals-title">
<h3 id="proposals-title" class="sub-title">Waiting on you</h3>
<p v-if="failed" class="error-msg">
The proposals could not be loaded.
<button type="button" class="btn-text" @click="load">Try again</button>
</p>
<template v-else>
<p class="field-hint">
Moments these rules may belong on, or may not. Mounting one makes the rule arrive
whenever that moment happens, whatever the work is about; declining keeps it from being
proposed again. A mount that sessions keep reporting as beside the point can be taken
off — or, when one action is reaching the moment wrongly, unmapped in the list below.
</p>
<ul class="proposal-list">
<li v-for="rule in rules" :key="rule.id" class="proposal-rule">
<div class="rule-head">
<span class="rule-title">#{{ rule.id }} {{ rule.title }}</span>
<span v-if="rule.mounted.length" class="rule-mounted">
on <code v-for="m in rule.mounted" :key="m" class="moment-name">{{ m }}</code>
</span>
</div>
<p class="rule-statement">{{ rule.statement }}</p>
<ul class="proposal-items">
<li v-for="p in rule.proposals" :key="key(rule, p)" class="proposal-item">
<span v-if="p.proposal === 'unmount'" class="proposal-kind">take off</span>
<code class="moment-name">{{ p.moment }}</code>
<span class="proposal-why">
{{ p.why || sourceLabel(p) }}
<span v-if="p.why" class="proposal-source">· {{ sourceLabel(p) }}</span>
<span v-if="actions(p).length" class="proposal-source">
· reached by {{ actions(p).join(", ") }}
</span>
</span>
<span v-if="p.proposal === 'unmount'" class="proposal-actions">
<button
type="button" class="btn-primary btn-sm"
:disabled="busy !== null"
@click="decide(rule, p, 'reject')"
>Take it off</button>
<button
type="button" class="btn-text"
:disabled="busy !== null"
@click="decide(rule, p, 'confirm')"
>Keep it</button>
</span>
<span v-else class="proposal-actions">
<button
type="button" class="btn-primary btn-sm"
:disabled="busy !== null"
@click="decide(rule, p, 'confirm')"
>Mount</button>
<button
type="button" class="btn-text"
:disabled="busy !== null"
@click="decide(rule, p, 'reject')"
>Not this</button>
</span>
<ul v-if="p.reasons && p.reasons.length > 1" class="proposal-reasons">
<li v-for="r in p.reasons.slice(0, -1).reverse()" :key="r.at">{{ r.why }}</li>
</ul>
</li>
</ul>
</li>
</ul>
</template>
</section>
</template>
<style scoped>
.proposals { display: flex; flex-direction: column; gap: var(--fs-space-2); }
.sub-title { margin: 0; font-size: 0.95rem; color: var(--fs-text-primary); }
.proposal-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
.proposal-rule {
padding: var(--fs-space-3);
background: var(--fs-surface-page);
border: 1px dashed var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
.rule-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--fs-space-2); }
.rule-title { font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); font-weight: var(--fs-weight-medium); }
.rule-mounted { display: inline-flex; flex-wrap: wrap; gap: var(--fs-space-1); font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); }
.rule-statement { margin: var(--fs-space-1) 0 var(--fs-space-2); font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); line-height: var(--fs-leading-body); }
.proposal-items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
.proposal-item { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--fs-space-2); font-size: var(--fs-size-body-sm); }
.proposal-why { flex: 1 1 16rem; color: var(--fs-text-secondary); }
.proposal-source { color: var(--fs-text-tertiary); font-size: var(--fs-size-tiny); }
.proposal-actions { display: inline-flex; gap: var(--fs-space-2); margin-left: auto; }
.proposal-kind { font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); }
.proposal-reasons {
flex-basis: 100%;
margin: 0;
padding-left: var(--fs-space-4);
font-size: var(--fs-size-tiny);
color: var(--fs-text-tertiary);
}
</style>
<style src="@/assets/moments-shared.css" />