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
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>
191 lines
7.9 KiB
Vue
191 lines
7.9 KiB
Vue
<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" />
|