feat(moments): mount the corpus by proposal - a pass and an open-after-moment signal, both stopping at the operator (milestone 458 step 7, #4925)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / integration (push) Failing after 43s
CI & Build / Python tests (push) Failing after 46s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Build & push image (push) Skipped

A rule written before moments existed is mounted on nothing. Step 7 records,
per (rule, moment), whether it belongs there and who said so:

- rule_moment_judgments (migration 0118, backup v23): suggested / confirmed /
  rejected, from a pass, the signal, or an edit. Moment "" is "no moment fits".
- The pass: rules_to_mount lists unjudged rules; propose_rule_moments records
  suggestions that mount nothing; rule_moment_proposals and
  judge_rule_moments put them to the operator. A confirm mounts, a reject is
  kept so the pair is never proposed again. Same service behind REST and a
  "Waiting on you" panel in Settings > Moments.
- Edits are judgments: set_rule_moments, the one mount write path, confirms
  what was added and rejects what was removed in the same transaction.
- The signal: scribe_moment.sh keeps a per-session acts ledger; when a rule
  is opened, scribe_record_opened.sh sends the last three minutes of it to
  /api/plugin/rule-opened. The acts resolve through the install's mappings;
  work.run and work.change are not evidence. Counted per distinct session
  with lesson_rules' evidence model, and once due the open returns one line
  asking the reader to offer the mount.
- scribe_session_end.sh removes the session's scribe-moment files.

Plugin 2026.10.05.2003.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 16:03:53 -04:00
co-authored by Claude Opus 5.5
parent 5bcc603310
commit dfcf4df2e9
24 changed files with 1619 additions and 31 deletions
+138
View File
@@ -0,0 +1,138 @@
<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 that a rule belongs on a moment, waiting on a person (milestone
* 458 step 7). They come from a pass that read the rule, or from the rule
* being opened just after the moment fired in several sessions. Nothing here
* is mounted until "Mount" is pressed — the same 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 sourceLabel(p: MomentProposal): string {
if (p.source === "signal") {
return `opened just after this moment in ${p.evidence.situations} sessions`;
}
return "proposed from the rule's text";
}
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: verdict === "confirm" ? "Mounted in Settings." : "Declined in Settings.",
}]);
if (out.refused.length) {
toast.show(out.refused[0].error, "error");
return;
}
await load();
// A mount changes the per-moment counts the list below shows.
if (verdict === "confirm") 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. Mounting one makes the rule arrive whenever that
moment happens, whatever the work is about; declining keeps it from being proposed again.
</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">
<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>
<span 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>
</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; }
</style>
<style src="@/assets/moments-shared.css" />