feat(moments): the human door onto mounts, mappings and per-moment telemetry (milestone 458 step 6, #4924)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m34s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 2m7s

Everything an agent can do with moments, a person can now see and change
in the app.

- Rule editor: a moment picker beside the trigger. Catalog moments are
  ticked; a named procedure's `skill.<name>` is typed and checked as the
  server checks it. `moments` is always sent, so unticking the last moment
  unmounts the rule.
- Settings, Moments section (General tab): for each moment, what it
  means, the actions that reach it on this install (shipped ones can be
  switched off, the install's own removed), how many rules are mounted on
  it, and deliveries and agent opens over the window. Below that: named
  procedures with mounts, switched-off defaults with Restore, and a form to
  add an action.
- retrieval_telemetry.moment_usage: per moment, `delivered`, `rules`,
  `opened` (agent pulls after the first delivery there; an upper bound, as
  by_source is) and `last_delivered_at`. No ratio, because a mount is a
  person's statement, not a ranker's guess. Guarded on its own, and also
  reported in retrieval_summary as `moment_usage`.
- rulebooks.mount_counts; mounted_moments now derives from it.
- GET /api/retrieval/moments carries `mounted` and `usage` (?days=).
  DELETE /moments/mappings also reads the mapping from query parameters,
  since the browser's DELETE sends no body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 14:42:30 -04:00
co-authored by Claude Opus 5.5
parent c489452a2d
commit b73a689849
12 changed files with 869 additions and 9 deletions
+101
View File
@@ -0,0 +1,101 @@
import { apiGet, apiPost, apiDelete } from "@/api/client";
/**
* The moments of work rules mount on (milestone 458), and the actions that
* reach each one on this install — `GET /api/retrieval/moments`, the payload
* the `list_moments` MCP tool returns, plus the counts the Settings view shows
* beside each moment.
*/
export interface Moment {
name: string;
/** What is happening at this moment. */
means: string;
/** The kinds of action that typically reach it. */
reached_by: string;
}
/** A shape rather than an entry: `skill.<name>`, one moment per procedure. */
export interface MomentFamily {
name: string;
prefix: string;
means: string;
reached_by: string;
}
/** `default` ships with the product; `install` is this install's own. */
export type ActionVia = "default" | "install";
export interface MomentAction {
tool: string;
/** Empty = every call of the tool. A command prefix for a command tool,
* `field=value` pairs for any other. */
match: string;
via: ActionVia;
}
/** A shipped default this install switched off. */
export interface RemovedDefault {
id: number;
tool: string;
match: string;
moment: string;
reason: string | null;
actor: string | null;
created_at: string | null;
}
export interface MomentUsage {
/** How many times a mounted rule arrived at this moment. */
delivered: number;
/** How many distinct rules did. */
rules: number;
/** Of those, how many an agent then opened — an upper bound: a pull records
* the door, not the line that prompted it. */
opened: number;
last_delivered_at: string | null;
}
export interface MomentsPayload {
moments: Moment[];
families: MomentFamily[];
total: number;
actions: Record<string, MomentAction[]>;
removed_defaults: RemovedDefault[];
/** Rules mounted per moment; a moment carrying none is absent. */
mounted: Record<string, number>;
/** The count read failed — `mounted` is empty for that reason, not because
* nothing is mounted. */
mounted_failed?: boolean;
usage: {
by_moment: Record<string, MomentUsage>;
days: number;
/** Same distinction as `mounted_failed`, for the usage read. */
moment_usage_failed?: boolean;
};
}
export interface MappingChange {
tool: string;
match?: string;
moment: string;
reason?: string;
}
export function getMoments(days = 30): Promise<MomentsPayload> {
return apiGet(`/api/retrieval/moments?days=${days}`);
}
export function mapAction(change: MappingChange): Promise<unknown> {
return apiPost("/api/retrieval/moments/mappings", change);
}
/** Query parameters, not a body: DELETE bodies are not reliably sent. */
export function unmapAction(change: MappingChange): Promise<void> {
const q = new URLSearchParams({
tool: change.tool,
match: change.match ?? "",
moment: change.moment,
reason: change.reason ?? "",
});
return apiDelete(`/api/retrieval/moments/mappings?${q.toString()}`);
}
+5
View File
@@ -82,6 +82,9 @@ export interface Rule {
updated_at: string | null;
/** Present only when the rule has them (the server omits empty keys). */
systems?: { id: number; name: string }[];
/** The moments this rule arrives at whenever they happen (milestone 458),
* in catalog order. Present only when the rule is mounted. */
moments?: string[];
relations?: RuleRelation[];
/** The lessons that point at this rule (milestone 440) — the concrete
* situations judged instances of it, plus any suggested and awaiting a
@@ -210,6 +213,8 @@ export interface RuleWrite {
how_to_apply: string;
order_index: number;
system_ids: number[];
/** Replaces the set; [] unmounts the rule from every moment. */
moments: string[];
arose_from_id: number | null;
verify_with: string;
expires_when: string;
+26
View File
@@ -0,0 +1,26 @@
/* Moments (milestone 458): the pieces the rule editor's picker and the
Settings section share, so a moment's name and a chip's remove control look
the same wherever a person meets them. Loaded unscoped by both. */
/* The name as a session reads it in a delivered line — shown as written. */
.moment-name {
font-family: var(--fs-font-mono);
color: var(--fs-text-primary);
}
/* The × on a removable chip: quiet until hovered, and keyboard-reachable. */
.chip-remove {
background: none;
border: none;
cursor: pointer;
padding: 0;
font: inherit;
color: var(--fs-text-tertiary);
}
.chip-remove:hover:not(:disabled) { color: var(--fs-text-primary); }
.chip-remove:disabled { opacity: var(--fs-disabled-opacity); cursor: default; }
.chip-remove:focus-visible {
outline: none;
box-shadow: var(--fs-focus-ring);
border-radius: var(--fs-radius-sm);
}
+294
View File
@@ -0,0 +1,294 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useMomentsStore } from "@/stores/moments";
import type { MomentAction, MomentUsage } from "@/api/moments";
import { fmtDate } from "@/utils/dateFormat";
/**
* The human door onto moments (milestone 458 step 6): every moment in the
* catalog, the actions that reach it on this install, how many rules ride on
* it, and what reaching it has delivered. The session's own door is
* `list_moments` / `map_action` / `unmap_action`, and both call the same
* services — this view exists so a person can see and change everything an
* agent can, without asking one to.
*/
const store = useMomentsStore();
const data = computed(() => store.data);
const catalog = computed(() => data.value?.moments ?? []);
const days = computed(() => data.value?.usage.days ?? 30);
const usageFailed = computed(() => !!data.value?.usage.moment_usage_failed);
function actionsFor(moment: string): MomentAction[] {
return data.value?.actions[moment] ?? [];
}
function mounted(moment: string): number {
return data.value?.mounted[moment] ?? 0;
}
function usage(moment: string): MomentUsage | null {
return data.value?.usage.by_moment[moment] ?? null;
}
// A named procedure is its own moment (`skill.<name>`), so it appears here
// only once something is mounted on it or has been delivered at it — the
// catalog cannot list procedures it has never heard of.
const procedureMoments = computed(() => {
const names = new Set<string>([
...Object.keys(data.value?.mounted ?? {}),
...Object.keys(data.value?.usage.by_moment ?? {}),
]);
return [...names].filter((n) => n.startsWith("skill.")).sort();
});
function actionLabel(a: { tool: string; match: string }): string {
return a.match ? `${a.tool} · ${a.match}` : a.tool;
}
const busy = ref(false);
async function run(fn: () => Promise<unknown>) {
busy.value = true;
try {
await fn();
} catch {
// The store has already said why; the row stays as it was.
} finally {
busy.value = false;
}
}
function removeAction(moment: string, a: MomentAction) {
// A shipped default is switched off rather than deleted, and comes back
// with "Restore" below — worth a confirm, since every later session on
// this install loses the moment for that action.
if (a.via === "default"
&& !confirm(`Stop ${actionLabel(a)} reaching ${moment} on this install? You can restore it below.`)) {
return;
}
void run(() => store.unmap({ tool: a.tool, match: a.match, moment, reason: "Removed in Settings." }));
}
function restore(r: { tool: string; match: string; moment: string }) {
void run(() => store.map({ tool: r.tool, match: r.match, moment: r.moment }));
}
// ── Add a mapping ─────────────────────────────────────────────────────────
const draftTool = ref("");
const draftMatch = ref("");
const draftMoment = ref("");
const draftReason = ref("");
const canAdd = computed(() => !!draftTool.value.trim() && !!draftMoment.value && !busy.value);
async function addMapping() {
if (!canAdd.value) return;
busy.value = true;
try {
await store.map({
tool: draftTool.value.trim(),
match: draftMatch.value.trim(),
moment: draftMoment.value,
reason: draftReason.value.trim(),
});
draftTool.value = "";
draftMatch.value = "";
draftReason.value = "";
} catch {
// Kept as typed, so the refusal the store showed can be acted on.
} finally {
busy.value = false;
}
}
onMounted(() => store.fetch(true));
</script>
<template>
<div class="moments-settings">
<p v-if="store.loading && !data" class="empty-msg">Loading moments…</p>
<p v-else-if="store.failed && !data" class="error-msg">
The moment catalog could not be loaded.
<button type="button" class="btn-text" @click="store.fetch(true)">Try again</button>
</p>
<template v-else-if="data">
<p class="window-note">
Deliveries and opens over the last {{ days }} days. “Opened” counts the rules an agent
went on to read — an upper bound, since a rule delivered at two moments and read once
counts at both.
</p>
<p v-if="usageFailed" class="error-msg">
The delivery counts could not be read, so they are missing below — not zero.
</p>
<p v-if="data.mounted_failed" class="error-msg">
The mount counts could not be read, so they are missing below — not zero.
</p>
<ul class="moment-list">
<li v-for="m in catalog" :key="m.name" class="moment-row">
<div class="moment-head">
<code class="moment-name">{{ m.name }}</code>
<span class="moment-stats">
<span :class="{ 'stat-none': !mounted(m.name) }">
{{ mounted(m.name) ? `${mounted(m.name)} ${mounted(m.name) === 1 ? "rule" : "rules"} mounted` : "nothing mounted" }}
</span>
<template v-if="usage(m.name)">
<span>delivered {{ usage(m.name)!.delivered }}</span>
<span>opened {{ usage(m.name)!.opened }} of {{ usage(m.name)!.rules }}</span>
<span v-if="usage(m.name)!.last_delivered_at" class="stat-when">
last {{ fmtDate(usage(m.name)!.last_delivered_at!) }}
</span>
</template>
</span>
</div>
<p class="moment-desc">{{ m.means }}</p>
<div class="moment-actions">
<span class="actions-label">Reached by</span>
<span v-if="!actionsFor(m.name).length" class="stat-none">
no action here — {{ m.reached_by }}
</span>
<span
v-for="a in actionsFor(m.name)"
:key="`${a.via}:${a.tool}:${a.match}`"
:class="['action-chip', { 'is-install': a.via === 'install' }]"
:title="a.via === 'install' ? 'Added on this install' : 'Ships with Scribe'"
>
<code>{{ actionLabel(a) }}</code>
<button
type="button"
class="chip-remove"
:disabled="busy"
:aria-label="a.via === 'install'
? `Remove ${actionLabel(a)} from ${m.name}`
: `Switch off the shipped ${actionLabel(a)} for ${m.name}`"
@click="removeAction(m.name, a)"
>×</button>
</span>
</div>
</li>
</ul>
<template v-if="procedureMoments.length">
<h3 class="sub-title">Named procedures</h3>
<ul class="moment-list">
<li v-for="name in procedureMoments" :key="name" class="moment-row">
<div class="moment-head">
<code class="moment-name">{{ name }}</code>
<span class="moment-stats">
<span :class="{ 'stat-none': !mounted(name) }">
{{ mounted(name) ? `${mounted(name)} ${mounted(name) === 1 ? "rule" : "rules"} mounted` : "nothing mounted" }}
</span>
<template v-if="usage(name)">
<span>delivered {{ usage(name)!.delivered }}</span>
<span>opened {{ usage(name)!.opened }} of {{ usage(name)!.rules }}</span>
</template>
</span>
</div>
</li>
</ul>
</template>
<template v-if="data.removed_defaults.length">
<h3 class="sub-title">Shipped mappings switched off here</h3>
<ul class="removed-list">
<li v-for="r in data.removed_defaults" :key="r.id" class="removed-row">
<code>{{ actionLabel(r) }}</code>
<span class="removed-arrow">→</span>
<code>{{ r.moment }}</code>
<span v-if="r.reason" class="removed-reason">{{ r.reason }}</span>
<button type="button" class="btn-text" :disabled="busy" @click="restore(r)">Restore</button>
</li>
</ul>
</template>
<form class="add-mapping" @submit.prevent="addMapping">
<h3 class="sub-title">Add an action</h3>
<p class="field-hint">
When something you do plainly happens at a moment and nothing fired — your own deploy
script, a tool from another MCP server. For a command tool, the match is how the command
starts (<code>make ship</code>); for any other tool, its arguments as
<code>field=value</code> pairs. Leave it empty to map every call.
</p>
<div class="add-grid">
<label>
Tool
<input v-model="draftTool" class="fs-input" placeholder="Bash, or an MCP tool's name" />
</label>
<label>
Match
<input v-model="draftMatch" class="fs-input" placeholder="make ship · status=done · empty for every call" />
</label>
<label>
Moment
<select v-model="draftMoment" class="fs-input">
<option value="" disabled>Choose a moment</option>
<option v-for="m in catalog" :key="m.name" :value="m.name">{{ m.name }}</option>
</select>
</label>
<label>
Why <span class="optional">(optional)</span>
<input v-model="draftReason" class="fs-input" placeholder="what it is for here" />
</label>
</div>
<button type="submit" class="btn-primary" :disabled="!canAdd">Add action</button>
</form>
</template>
</div>
</template>
<style scoped>
.moments-settings { display: flex; flex-direction: column; gap: var(--fs-space-3); }
.window-note { margin: 0; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); line-height: var(--fs-leading-body); }
.moment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
.moment-row {
padding: var(--fs-space-3);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
.moment-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--fs-space-2); }
.moment-head .moment-name { font-size: var(--fs-size-body-sm); }
.moment-stats {
margin-left: auto;
display: flex; flex-wrap: wrap; gap: var(--fs-space-3);
font-size: var(--fs-size-tiny); color: var(--fs-text-secondary);
font-variant-numeric: tabular-nums;
}
/* Nothing mounted is the ordinary state of most moments, not a fault. */
.stat-none { color: var(--fs-text-tertiary); font-style: italic; }
.stat-when { color: var(--fs-text-tertiary); }
.moment-desc { 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); }
.moment-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--fs-space-2); font-size: var(--fs-size-tiny); }
.actions-label { color: var(--fs-text-tertiary); text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny); }
.action-chip {
display: inline-flex; align-items: center; gap: var(--fs-space-1);
padding: 0.05rem 0.45rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
color: var(--fs-text-secondary);
}
.action-chip code { font-family: var(--fs-font-mono); }
/* This install's own, set apart from the shipped ones the way the tuning
trail marks the operator's changes: a dashed edge, no accent. */
.action-chip.is-install { border-style: dashed; color: var(--fs-text-primary); }
.sub-title { margin: var(--fs-space-3) 0 var(--fs-space-1); font-size: 0.95rem; color: var(--fs-text-primary); }
.removed-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-1); }
.removed-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--fs-space-2); font-size: var(--fs-size-body-sm); }
.removed-row code { font-family: var(--fs-font-mono); color: var(--fs-text-secondary); text-decoration: line-through; }
.removed-arrow { color: var(--fs-text-tertiary); }
.removed-reason { color: var(--fs-text-tertiary); font-size: var(--fs-size-tiny); }
.add-mapping { display: flex; flex-direction: column; gap: var(--fs-space-2); align-items: flex-start; }
.add-grid {
width: 100%;
display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: var(--fs-space-2) var(--fs-space-3);
}
.add-grid label { display: flex; flex-direction: column; gap: var(--fs-space-1); font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); }
.optional { color: var(--fs-text-tertiary); font-weight: normal; }
</style>
<style src="@/assets/moments-shared.css" />
@@ -2,6 +2,7 @@
import { computed, ref, watch, onMounted } from "vue";
import { useRulebooksStore } from "@/stores/rulebooks";
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
import { useMomentsStore } from "@/stores/moments";
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
import RuleHomePicker from "@/components/rules/RuleHomePicker.vue";
import type { Rule, RuleKind } from "@/api/rulebooks";
@@ -11,6 +12,7 @@ const emit = defineEmits<{ close: [] }>();
const store = useRulebooksStore();
const canon = useCanonicalSystemsStore();
const momentsStore = useMomentsStore();
const title = ref("");
const statement = ref("");
const whenToApply = ref("");
@@ -20,6 +22,18 @@ const whenToApply = ref("");
// session may quietly rewrite.
const kind = ref<RuleKind>("rule");
const systemIds = ref<number[]>([]);
// The moments this rule is mounted on (milestone 458). Catalog moments are
// ticked; a named procedure's moment (`skill.<name>`) is typed, since its
// name is the procedure's own and no list here could know it.
const ruleMoments = ref<string[]>([]);
const procedureDraft = ref("");
const procedureError = ref("");
const SKILL_PREFIX = "skill.";
const SKILL_NAME = /^[a-z0-9][a-z0-9_:-]*$/;
const momentCatalog = computed(() => momentsStore.data?.moments ?? []);
const procedureMoments = computed(() =>
ruleMoments.value.filter((m) => m.startsWith(SKILL_PREFIX)),
);
const why = ref("");
const howToApply = ref("");
const verifyWith = ref("");
@@ -46,6 +60,26 @@ function relationLabel(kind: string, direction: "outgoing" | "incoming") {
return RELATION_LABEL[kind]?.[direction] ?? kind;
}
function toggleMoment(name: string) {
const at = ruleMoments.value.indexOf(name);
if (at >= 0) ruleMoments.value.splice(at, 1);
else ruleMoments.value.push(name);
}
// The same check the server makes (services/moments.is_moment), so a typo is
// caught at the field rather than as a failed save.
function addProcedureMoment() {
const raw = procedureDraft.value.trim().toLowerCase();
const name = raw.startsWith(SKILL_PREFIX) ? raw : SKILL_PREFIX + raw;
if (!SKILL_NAME.test(name.slice(SKILL_PREFIX.length))) {
procedureError.value = "A procedure name is lowercase letters, digits, - _ and :, with no spaces.";
return;
}
procedureError.value = "";
if (!ruleMoments.value.includes(name)) ruleMoments.value.push(name);
procedureDraft.value = "";
}
function toggleSystem(id: number) {
const at = systemIds.value.indexOf(id);
if (at >= 0) systemIds.value.splice(at, 1);
@@ -85,6 +119,7 @@ async function load() {
whenToApply.value = r.when_to_apply || "";
kind.value = r.kind;
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
ruleMoments.value = [...(r.moments ?? [])];
why.value = r.why || "";
howToApply.value = r.how_to_apply || "";
verifyWith.value = r.verify_with || "";
@@ -96,12 +131,15 @@ async function load() {
whenToApply.value = "";
kind.value = "rule";
systemIds.value = [];
ruleMoments.value = [];
why.value = "";
howToApply.value = "";
verifyWith.value = "";
expiresWhen.value = "";
}
await canon.fetchCatalog();
procedureDraft.value = "";
procedureError.value = "";
await Promise.all([canon.fetchCatalog(), momentsStore.fetch()]);
}
async function save() {
@@ -117,6 +155,9 @@ async function save() {
// Always sent, so clearing the last area actually clears it — the server
// reads a list as "these ARE the areas now".
system_ids: systemIds.value,
// Always sent, for system_ids' reason: the server reads the list as the
// whole set, so unticking the last moment actually unmounts the rule.
moments: ruleMoments.value,
why: why.value,
how_to_apply: howToApply.value,
// Always sent, including empty. The REST door maps "" to NULL, so
@@ -210,6 +251,54 @@ watch(() => props.ruleId, load);
its trigger, so an empty trigger leaves the rule findable by nobody.
</p>
<fieldset v-if="momentCatalog.length" class="moments">
<legend>Moments it arrives at</legend>
<p class="field-note intro">
Whenever one of these happens, this rule is delivered — whatever the
words of the work look like. Use it for a rule about <em>when</em>
something is done rather than what it is about: a rule on when work
counts as finished belongs where work is finished and reported, and
nothing said there needs to resemble it.
</p>
<label v-for="m in momentCatalog" :key="m.name" class="area-opt">
<input
type="checkbox"
:checked="ruleMoments.includes(m.name)"
@change="toggleMoment(m.name)"
/>
<span>
<code class="moment-name">{{ m.name }}</code>
<span class="moment-means">{{ m.means }}</span>
</span>
</label>
<div v-if="procedureMoments.length" class="procedure-moments">
<span v-for="m in procedureMoments" :key="m" class="rule-chip procedure-chip">
<code>{{ m }}</code>
<button
type="button"
class="chip-remove"
:aria-label="`Unmount from ${m}`"
@click="toggleMoment(m)"
>×</button>
</span>
</div>
<div class="procedure-add">
<input
v-model="procedureDraft"
aria-label="A named procedure this rule arrives with"
placeholder="skill.release-notes — when a named procedure is loaded"
@keydown.enter.prevent="addProcedureMoment"
/>
<button
type="button"
class="btn-secondary btn-sm"
:disabled="!procedureDraft.trim()"
@click="addProcedureMoment"
>Add</button>
</div>
<p v-if="procedureError" class="trigger-warning">{{ procedureError }}</p>
</fieldset>
<fieldset v-if="canon.catalog.length" class="areas">
<legend>Areas this rule is about</legend>
<label v-for="entry in canon.catalog" :key="entry.id" class="area-opt">
@@ -370,6 +459,17 @@ legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary);
.area-opt { display: flex; align-items: flex-start; gap: 0.5rem; margin-bottom: 0.4rem; font-size: 0.88rem; }
.area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
.trigger-missing textarea { border-color: var(--fs-warning); }
/* The moment picker (milestone 458): the name is what a session reads in a
delivered line, so it is shown as written; the meaning is the gloss. */
.moments { margin-bottom: 1rem; }
.moments .intro { margin-top: 0; margin-bottom: var(--fs-space-3); }
.moments .moment-name { font-size: var(--fs-size-tiny); }
.moment-means { display: block; font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary); line-height: var(--fs-leading-body); }
.procedure-moments { display: flex; flex-wrap: wrap; gap: var(--fs-space-2); margin: var(--fs-space-2) 0; }
.procedure-chip { display: inline-flex; align-items: center; gap: var(--fs-space-1); }
.procedure-add { display: flex; gap: var(--fs-space-2); align-items: center; margin-top: var(--fs-space-2); }
.procedure-add input { margin-top: 0; }
.procedure-add .btn-sm { flex: none; }
/* --fs-warning-fg, not --fs-warning: the token set draws the distinction
between the warning HUE and warning text, and this is text. */
.trigger-warning {
@@ -422,3 +522,4 @@ legend { padding: 0 0.35rem; font-size: 0.8rem; color: var(--fs-text-tertiary);
panes already use, loaded here because the slide-over can open with no
pane that loads it (a link straight to ?rule=N). -->
<style src="@/assets/rules-shared.css" />
<style src="@/assets/moments-shared.css" />
+56
View File
@@ -0,0 +1,56 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import * as api from "@/api/moments";
import type { MappingChange, MomentsPayload } from "@/api/moments";
import { useToastStore } from "@/stores/toast";
import { apiErrorMessage } from "@/api/client";
/**
* The moment catalog and this install's mappings (milestone 458 step 6).
*
* Shared by the rule editor's picker and the Settings section, so the two
* cannot offer different moments. The catalog is the product's and changes
* only with a release; the mappings and counts change with every write here,
* so a write re-reads the whole payload rather than patching it locally.
*/
export const useMomentsStore = defineStore("moments", () => {
const data = ref<MomentsPayload | null>(null);
const loading = ref(false);
const failed = ref(false);
async function fetch(force = false) {
if (data.value && !force) return data.value;
loading.value = true;
try {
data.value = await api.getMoments();
failed.value = false;
} catch {
// The picker rides on the rule editor: an unreachable catalog hides
// the picker, it does not break the form it sits in.
failed.value = true;
} finally {
loading.value = false;
}
return data.value;
}
async function change(kind: "map" | "unmap", mapping: MappingChange) {
try {
if (kind === "map") await api.mapAction(mapping);
else await api.unmapAction(mapping);
} catch (e) {
useToastStore().show(
apiErrorMessage(e, kind === "map" ? "Could not add the mapping" : "Could not remove the mapping"),
"error",
);
throw e;
}
await fetch(true);
}
return {
data, loading, failed, fetch,
map: (m: MappingChange) => change("map", m),
unmap: (m: MappingChange) => change("unmap", m),
};
});
+12
View File
@@ -8,6 +8,7 @@ import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGrou
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
import MomentsSettings from "@/components/MomentsSettings.vue";
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
import { fetchVersion, type VersionPayload } from "@/api/version";
@@ -2222,6 +2223,17 @@ async function deleteUser(userId: number) {
</div>
</section>
<section class="settings-section full-width">
<h2>Moments</h2>
<p class="section-desc">
A rule mounted on a moment arrives whenever that moment happens, whatever the work
looks like — which is how a rule about <em>when</em> something is done reaches a session
that never mentions it. Mount rules from the rule editor; here is what each moment
means, which actions reach it on this install, and what it has delivered.
</p>
<MomentsSettings />
</section>
</div>
<!-- ── Account ── -->
+30 -4
View File
@@ -13,12 +13,15 @@ So every change to a retrieval dial goes through `set_dial`, from the UI as
much as from the MCP tool, and the only difference is the `actor` recorded.
"""
import logging
from datetime import datetime, timedelta, timezone
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.services import moment_actions as moment_actions_svc
from scribe.services import moments as moments_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services.retrieval_telemetry import moment_usage
from scribe.services.retrieval_tuning import set_dial, current_settings, tuning_history
logger = logging.getLogger(__name__)
@@ -115,16 +118,39 @@ async def moments_route():
actions that reach each one on this install.
The same payload `list_moments` returns, from the same services, so the
session and the Settings view cannot name different moments.
session and the Settings view cannot name different moments — plus what
the view shows beside each one (step 6): `mounted`, how many rules each
carries, and `usage`, the deliveries and opens per moment over `days`
(the block `retrieval_telemetry` reports as `moment_usage`).
"""
uid = get_current_user_id()
try:
days = max(1, min(int(request.args.get("days", 30)), 365))
except ValueError:
return jsonify({"error": "days must be a whole number"}), 400
out = moments_svc.catalog()
out.update(await moment_actions_svc.actions_by_moment(get_current_user_id()))
out.update(await moment_actions_svc.actions_by_moment(uid))
# The catalog is the page; the counts beside it are not worth losing it
# over. A failed count is flagged rather than shown as "nothing mounted".
try:
out["mounted"] = await rulebooks_svc.mount_counts(uid)
except Exception:
logger.warning("mount counts unreadable", exc_info=True)
out["mounted"], out["mounted_failed"] = {}, True
out["usage"] = await moment_usage(uid, datetime.now(timezone.utc) - timedelta(days=days))
out["usage"]["days"] = days
return jsonify(out)
async def _mapping_change(change):
"""map/unmap from the browser: the MCP tools' service, recorded as human."""
data = await request.get_json()
"""map/unmap from the browser: the MCP tools' service, recorded as human.
The mapping comes as a JSON body, or — for DELETE, whose body many
clients will not send — as query parameters of the same names.
"""
data = await request.get_json(silent=True)
if data is None and request.args:
data = request.args.to_dict()
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
if not data.get("tool") or not data.get("moment"):
+91 -1
View File
@@ -22,7 +22,8 @@ from typing import Any
from datetime import datetime, timedelta, timezone
from sqlalchemy import case, func, select
from sqlalchemy import case, exists, func, select
from sqlalchemy.orm import aliased
from scribe.models import async_session
from scribe.models.base import iso
@@ -985,6 +986,94 @@ async def _system_usage(user_id: int | None, since) -> dict:
return block
async def moment_usage(user_id: int | None, since) -> dict:
"""Deliveries and opens per moment (milestone 458 step 6).
A mounted rule's delivery is a `moment_rule` surfacing whose `detail` is
the moment it arrived at — the plugin's catch-all hook, the MCP tools'
attached lines and the reply hold all record it so. Per moment:
- `delivered`: how many times a mounted rule arrived there;
- `rules`: how many distinct rules did;
- `opened`: how many of those an AGENT then opened (`mcp_*`), at or after
their first delivery there in the window;
- `last_delivered_at`.
`opened` is an UPPER BOUND, for by_source's reason: a pull records the
door, not the line that prompted it, so a rule delivered at two moments
and opened once counts as opened at both. A moment reading near zero is
not being flattered by it.
NO RATIO. A mount is a person's statement that the rule belongs at that
moment, not a ranker's guess, so "delivered often, opened rarely" is a
question about the rule's wording or the agent, not a bar to tune. The
counts sit side by side.
Its own session and guard (#2663): a failure keeps the shape and adds
`moment_usage_failed`, never zeros that read as "nothing happened".
"""
from scribe.services.retrieval_pipeline import MOMENT_RULE_SOURCE
block: dict = {"by_moment": {}}
try:
async with async_session() as session:
per_rule = (
select(
RuleUsageEvent.detail.label("moment"),
RuleUsageEvent.rule_id,
func.count().label("n"),
func.min(RuleUsageEvent.created_at).label("first_at"),
func.max(RuleUsageEvent.created_at).label("last_at"),
)
.where(
RuleUsageEvent.created_at >= since,
RuleUsageEvent.user_id == user_id,
RuleUsageEvent.event == RULE_SURFACED,
RuleUsageEvent.source == MOMENT_RULE_SOURCE,
RuleUsageEvent.detail.is_not(None),
)
.group_by(RuleUsageEvent.detail, RuleUsageEvent.rule_id)
.subquery()
)
pull = aliased(RuleUsageEvent)
opened = exists().where(
pull.rule_id == per_rule.c.rule_id,
pull.user_id == user_id,
pull.event == RULE_PULLED,
# autoescape: `_` is a LIKE wildcard (see the note block).
pull.source.startswith("mcp_", autoescape=True),
pull.created_at >= per_rule.c.first_at,
)
rows = (
await session.execute(
select(
per_rule.c.moment,
func.sum(per_rule.c.n).label("delivered"),
func.count().label("rules"),
func.count(case((opened, 1))).label("opened"),
func.max(per_rule.c.last_at).label("last_at"),
).group_by(per_rule.c.moment)
)
).all()
complete = await _complete_from(session, RuleUsageEvent, user_id)
except Exception:
logger.warning("moment usage read failed", exc_info=True)
block["moment_usage_failed"] = True
return block
block["by_moment"] = {
moment: {
"delivered": int(delivered or 0),
"rules": int(rules or 0),
"opened": int(n_opened or 0),
"last_delivered_at": iso(last_at),
}
for moment, delivered, rules, n_opened, last_at in rows
}
block.update(_coverage(complete.get("*"), since))
return block
async def retrieval_summary(
user_id: int | None, *, days: int = 30, near_miss_samples: int = 0,
) -> dict:
@@ -1583,6 +1672,7 @@ async def retrieval_summary(
rule_usage.update(_coverage((rule_complete or {}).get("*"), since))
out["rule_usage"] = rule_usage
out["system_usage"] = await _system_usage(user_id, since)
out["moment_usage"] = await moment_usage(user_id, since)
# ── Judged lines (#4772) ─────────────────────────────────────────────
#
+14 -3
View File
@@ -1208,6 +1208,16 @@ async def mounted_moments(user_id: int) -> set[str]:
tool whose moments carry nothing. A superset is the safe direction — the
delivery read still scopes by project.
"""
return set(await mount_counts(user_id))
async def mount_counts(user_id: int) -> dict[str, int]:
"""How many of this caller's live rules are mounted on each moment.
The same read as `mounted_moments`, counted — what the Settings view
shows beside each moment, so a moment carrying nothing reads as such.
Moments with no mount are absent rather than zero.
"""
from scribe.models.rulebook import rule_moments as rule_moments_t
from scribe.services.rule_scope import joined_to_homes, rule_home
@@ -1215,13 +1225,14 @@ async def mounted_moments(user_id: int) -> set[str]:
async with async_session() as session:
rows = (await session.execute(
joined_to_homes(
select(rule_moments_t.c.moment).distinct()
select(rule_moments_t.c.moment, func.count(func.distinct(Rule.id)))
.select_from(rule_moments_t)
.join(Rule, Rule.id == rule_moments_t.c.rule_id)
)
.where(Rule.deleted_at.is_(None), home)
)).scalars().all()
return set(rows)
.group_by(rule_moments_t.c.moment)
)).all()
return {moment: int(n) for moment, n in rows}
async def list_rule_systems(rule_ids: list[int]) -> dict[int, list[dict]]:
+61
View File
@@ -199,3 +199,64 @@ async def test_the_delivery_lookup_answers_by_home(world):
# The plugin's "can anything arrive" answer spans every home.
assert await rulebooks_svc.mounted_moments(uid) >= {"work.finish", "reply.report", "work.deliver"}
assert not (await rulebooks_svc.mounted_moments(sid)) & {"work.finish", "work.deliver"}
# Counted the same way for the Settings view: a rule mounted twice counts
# once per moment, and another user's mounts are not in the count.
counts = await rulebooks_svc.mount_counts(uid)
assert counts.get("work.deliver", 0) >= 1 and counts.get("reply.report", 0) >= 1
assert set(counts) == await rulebooks_svc.mounted_moments(uid)
assert not set(await rulebooks_svc.mount_counts(sid)) & {"work.finish", "work.deliver"}
async def test_moment_usage_counts_deliveries_and_the_agent_opens_that_followed(world):
"""The per-moment readout (step 6), on real SQL: an EXISTS inside a
COUNT(CASE …) is a shape no mock can vouch for, and the block's own guard
would turn a database refusal into a quiet `moment_usage_failed`."""
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete
from scribe.models.rule_usage import PULLED, SURFACED, RuleUsageEvent
from scribe.services.retrieval_pipeline import MOMENT_RULE_SOURCE
from scribe.services.retrieval_telemetry import moment_usage
uid, sid, rule = world["uid"], world["sid"], world["rule"]
other = await rulebooks_svc.create_rule(
rule.topic_id, uid, "Close the task with the push",
"A task closes in the turn its CI goes green.",
when_to_apply="the CI run on a pushed commit has just gone green",
)
now = datetime.now(timezone.utc)
def ev(user, rule_id, event, source, ago, detail=None):
return RuleUsageEvent(user_id=user, rule_id=rule_id, event=event, source=source,
detail=detail, created_at=now - ago)
async with async_session() as s:
await s.execute(delete(RuleUsageEvent).where(RuleUsageEvent.user_id.in_([uid, sid])))
s.add_all([
ev(uid, rule.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(hours=3), "work.finish"),
ev(uid, rule.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(hours=2), "work.finish"),
ev(uid, other.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(hours=1), "work.finish"),
ev(uid, rule.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(hours=1), "reply.report"),
# An agent opened `rule` after its first work.finish delivery, and
# BEFORE its reply.report one — so it counts at the first only.
ev(uid, rule.id, PULLED, "mcp_get_rule", timedelta(minutes=150)),
# A person opening `other` in the browser is not an agent open.
ev(uid, other.id, PULLED, "rest_rule", timedelta(minutes=5)),
# A ranked surfacing is not a delivery at a moment.
ev(uid, other.id, SURFACED, "pre_tool_rule", timedelta(minutes=5)),
# Nor is a delivery older than the window, or someone else's.
ev(uid, other.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(days=3), "work.deliver"),
ev(sid, rule.id, SURFACED, MOMENT_RULE_SOURCE, timedelta(minutes=5), "work.finish"),
])
await s.commit()
block = await moment_usage(uid, now - timedelta(days=1))
assert "moment_usage_failed" not in block
assert set(block["by_moment"]) == {"work.finish", "reply.report"}
finish = block["by_moment"]["work.finish"]
assert (finish["delivered"], finish["rules"], finish["opened"]) == (3, 2, 1)
report = block["by_moment"]["reply.report"]
assert (report["delivered"], report["rules"], report["opened"]) == (1, 1, 0)
assert report["last_delivered_at"]
+77
View File
@@ -133,3 +133,80 @@ async def test_setting_a_dial_to_the_value_it_already_has_records_nothing():
# And the setting is left alone too: rewriting the same value would bump
# whatever timestamp the row carries for no reason.
setter.assert_not_called()
# ── the moments view (milestone 458 step 6) ─────────────────────────────
async def _moments_call(handler_name, path, method="GET", **kw):
from types import SimpleNamespace
from quart import Quart, g
from scribe.routes import retrieval as routes
app = Quart(__name__)
async with app.test_request_context(path, method=method, **kw):
g.user = SimpleNamespace(id=7)
resp = await getattr(routes, handler_name).__wrapped__()
if isinstance(resp, tuple):
return await resp[0].get_json(), resp[1]
return await resp.get_json(), 200
async def test_the_view_gets_the_catalog_with_counts_beside_it():
from unittest.mock import AsyncMock, patch
from scribe.routes import retrieval as routes
usage = AsyncMock(return_value={"by_moment": {"work.finish": {"delivered": 2}}})
with patch.object(routes.moment_actions_svc, "actions_by_moment",
AsyncMock(return_value={"actions": {}, "removed_defaults": []})), \
patch.object(routes.rulebooks_svc, "mount_counts", AsyncMock(return_value={"work.finish": 1})), \
patch.object(routes, "moment_usage", usage):
body, status = await _moments_call("moments_route", "/api/retrieval/moments",
query_string={"days": "7"})
assert status == 200
assert body["mounted"] == {"work.finish": 1}
assert body["usage"]["days"] == 7
assert body["usage"]["by_moment"]["work.finish"]["delivered"] == 2
assert {m["name"] for m in body["moments"]} >= {"work.finish", "reply.report"}
async def test_a_failed_mount_count_is_flagged_not_shown_as_nothing():
from unittest.mock import AsyncMock, patch
from scribe.routes import retrieval as routes
with patch.object(routes.moment_actions_svc, "actions_by_moment",
AsyncMock(return_value={"actions": {}, "removed_defaults": []})), \
patch.object(routes.rulebooks_svc, "mount_counts", AsyncMock(side_effect=RuntimeError("down"))), \
patch.object(routes, "moment_usage", AsyncMock(return_value={"by_moment": {}})):
body, status = await _moments_call("moments_route", "/api/retrieval/moments")
assert status == 200
assert body["mounted"] == {} and body["mounted_failed"] is True
assert body["usage"]["days"] == 30
async def test_a_mapping_removal_can_come_as_query_parameters():
"""The browser's DELETE carries no body (api/client.apiDelete), so the
mapping it removes arrives in the query string."""
from unittest.mock import AsyncMock, patch
from scribe.routes import retrieval as routes
unmap = AsyncMock(return_value={"change": "removed this install's mapping"})
with patch.object(routes.moment_actions_svc, "unmap_action", unmap):
body, status = await _moments_call(
"unmap_action_route", "/api/retrieval/moments/mappings", method="DELETE",
query_string={"tool": "Bash", "match": "make ship", "moment": "work.deliver"},
)
assert status == 200
assert unmap.await_args.args == (7, "Bash", "make ship", "work.deliver")
assert unmap.await_args.kwargs["actor"] == "human"
async def test_a_mapping_with_neither_body_nor_parameters_is_refused():
body, status = await _moments_call(
"unmap_action_route", "/api/retrieval/moments/mappings", method="DELETE",
)
assert status == 400