diff --git a/frontend/src/api/moments.ts b/frontend/src/api/moments.ts
new file mode 100644
index 00000000..9a8f8581
--- /dev/null
+++ b/frontend/src/api/moments.ts
@@ -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.`, 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;
+ removed_defaults: RemovedDefault[];
+ /** Rules mounted per moment; a moment carrying none is absent. */
+ mounted: Record;
+ /** The count read failed — `mounted` is empty for that reason, not because
+ * nothing is mounted. */
+ mounted_failed?: boolean;
+ usage: {
+ by_moment: Record;
+ 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 {
+ return apiGet(`/api/retrieval/moments?days=${days}`);
+}
+
+export function mapAction(change: MappingChange): Promise {
+ return apiPost("/api/retrieval/moments/mappings", change);
+}
+
+/** Query parameters, not a body: DELETE bodies are not reliably sent. */
+export function unmapAction(change: MappingChange): Promise {
+ const q = new URLSearchParams({
+ tool: change.tool,
+ match: change.match ?? "",
+ moment: change.moment,
+ reason: change.reason ?? "",
+ });
+ return apiDelete(`/api/retrieval/moments/mappings?${q.toString()}`);
+}
diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts
index 16c3c25b..e84f10c0 100644
--- a/frontend/src/api/rulebooks.ts
+++ b/frontend/src/api/rulebooks.ts
@@ -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;
diff --git a/frontend/src/assets/moments-shared.css b/frontend/src/assets/moments-shared.css
new file mode 100644
index 00000000..0bb072f0
--- /dev/null
+++ b/frontend/src/assets/moments-shared.css
@@ -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);
+}
diff --git a/frontend/src/components/MomentsSettings.vue b/frontend/src/components/MomentsSettings.vue
new file mode 100644
index 00000000..b2a07a67
--- /dev/null
+++ b/frontend/src/components/MomentsSettings.vue
@@ -0,0 +1,294 @@
+
+
+
+
+
Loading moments…
+
+ The moment catalog could not be loaded.
+ Try again
+
+
+
+
+ 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.
+
+
+ The delivery counts could not be read, so they are missing below — not zero.
+
+
+ The mount counts could not be read, so they are missing below — not zero.
+
+
+
+
+
+ {{ m.name }}
+
+
+ {{ mounted(m.name) ? `${mounted(m.name)} ${mounted(m.name) === 1 ? "rule" : "rules"} mounted` : "nothing mounted" }}
+
+
+ delivered {{ usage(m.name)!.delivered }}
+ opened {{ usage(m.name)!.opened }} of {{ usage(m.name)!.rules }}
+
+ last {{ fmtDate(usage(m.name)!.last_delivered_at!) }}
+
+
+
+
+ {{ m.means }}
+
+ Reached by
+
+ no action here — {{ m.reached_by }}
+
+
+ {{ actionLabel(a) }}
+ ×
+
+
+
+
+
+
+ Named procedures
+
+
+
+
+ Shipped mappings switched off here
+
+
+ {{ actionLabel(r) }}
+ →
+ {{ r.moment }}
+ {{ r.reason }}
+ Restore
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/rules/RuleEditorSlideOver.vue b/frontend/src/components/rules/RuleEditorSlideOver.vue
index d33b7f97..b1450a7f 100644
--- a/frontend/src/components/rules/RuleEditorSlideOver.vue
+++ b/frontend/src/components/rules/RuleEditorSlideOver.vue
@@ -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("rule");
const systemIds = ref([]);
+// The moments this rule is mounted on (milestone 458). Catalog moments are
+// ticked; a named procedure's moment (`skill.`) is typed, since its
+// name is the procedure's own and no list here could know it.
+const ruleMoments = ref([]);
+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.
+
+ Moments it arrives at
+
+ Whenever one of these happens, this rule is delivered — whatever the
+ words of the work look like. Use it for a rule about when
+ 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.
+
+
+
+
+ {{ m.name }}
+ {{ m.means }}
+
+
+
+
+ {{ m }}
+ ×
+
+
+
+
+ Add
+
+ {{ procedureError }}
+
+
Areas this rule is about
@@ -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). -->
+
diff --git a/frontend/src/stores/moments.ts b/frontend/src/stores/moments.ts
new file mode 100644
index 00000000..eaa023fb
--- /dev/null
+++ b/frontend/src/stores/moments.ts
@@ -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(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),
+ };
+});
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index 5250207a..94875ae2 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -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) {
+
+ Moments
+
+ A rule mounted on a moment arrives whenever that moment happens, whatever the work
+ looks like — which is how a rule about when 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.
+
+
+
+
diff --git a/src/scribe/routes/retrieval.py b/src/scribe/routes/retrieval.py
index 88ffe0c7..f21d53c4 100644
--- a/src/scribe/routes/retrieval.py
+++ b/src/scribe/routes/retrieval.py
@@ -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"):
diff --git a/src/scribe/services/retrieval_telemetry.py b/src/scribe/services/retrieval_telemetry.py
index 59eb89f0..1d73ead9 100644
--- a/src/scribe/services/retrieval_telemetry.py
+++ b/src/scribe/services/retrieval_telemetry.py
@@ -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) ─────────────────────────────────────────────
#
diff --git a/src/scribe/services/rulebooks.py b/src/scribe/services/rulebooks.py
index 8f7d4365..96bfa988 100644
--- a/src/scribe/services/rulebooks.py
+++ b/src/scribe/services/rulebooks.py
@@ -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]]:
diff --git a/tests/test_integration_rule_moments.py b/tests/test_integration_rule_moments.py
index ab99140c..ba3f1347 100644
--- a/tests/test_integration_rule_moments.py
+++ b/tests/test_integration_rule_moments.py
@@ -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"]
diff --git a/tests/test_routes_retrieval_tuning.py b/tests/test_routes_retrieval_tuning.py
index 22ab4b14..ecc51db4 100644
--- a/tests/test_routes_retrieval_tuning.py
+++ b/tests/test_routes_retrieval_tuning.py
@@ -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