Files
FabledScribe/frontend/src/api/moments.ts
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

174 lines
5.1 KiB
TypeScript

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()}`);
}
/**
* 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. An `unmount` proposal (step
* 7b) comes from sessions reporting that the mount arrived where it did not
* apply. `GET /api/retrieval/moments/proposals`, the payload the
* `rule_moment_proposals` MCP tool returns.
*/
export type ProposalSource = "pass" | "signal" | "edit" | "misfire";
export interface MisfireReason {
why: string;
reached_by: string;
at: string;
}
export interface MomentProposal {
proposal: "mount" | "unmount";
moment: string;
source: ProposalSource;
why: string;
/** For a misfire, `situations` counts distinct days and `co_surfaced` the reports. */
evidence: { situations: number; projects: number; co_surfaced: number };
created_at: string | null;
/** Unmount proposals only: the newest reasons given, and how often each action reached the moment. */
reasons?: MisfireReason[];
reached_by?: Record<string, number>;
}
export interface RuleProposals {
id: number;
title: string;
kind: string;
statement: string;
when_to_apply: string;
home: "global" | "project";
/** What the rule is mounted on now. */
mounted: string[];
proposals: MomentProposal[];
}
export interface ProposalsPayload {
rules: RuleProposals[];
total: number;
}
export type Verdict = "confirm" | "reject";
export interface MomentJudgment {
rule_id: number;
moment: string;
verdict: Verdict;
note?: string;
}
export interface JudgeResult {
judged: { rule_id: number; moment: string; state: string }[];
refused: { rule_id: number; moment?: string; error: string }[];
}
export function getProposals(ruleId?: number): Promise<ProposalsPayload> {
return apiGet(`/api/retrieval/moments/proposals${ruleId ? `?rule_id=${ruleId}` : ""}`);
}
/**
* A confirm MOUNTS the rule on the moment (on an unmount proposal, keeps it);
* a reject unmounts it if mounted and stops the pair being proposed again.
*/
export function judgeProposals(judgments: MomentJudgment[]): Promise<JudgeResult> {
return apiPost("/api/retrieval/moments/proposals/judge", { judgments });
}