Merge pull request 'Skills and processes declare their moments, and the UI for moments (milestone 458 steps 5–6)' (#202) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m59s
CI & Build / Build & push image (push) Successful in 19s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m9s
CI & Build / Python tests (push) Successful in 1m59s
CI & Build / Build & push image (push) Successful in 19s
This commit was merged in pull request #202.
This commit is contained in:
@@ -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()}`);
|
||||||
|
}
|
||||||
@@ -82,6 +82,9 @@ export interface Rule {
|
|||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
/** Present only when the rule has them (the server omits empty keys). */
|
/** Present only when the rule has them (the server omits empty keys). */
|
||||||
systems?: { id: number; name: string }[];
|
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[];
|
relations?: RuleRelation[];
|
||||||
/** The lessons that point at this rule (milestone 440) — the concrete
|
/** The lessons that point at this rule (milestone 440) — the concrete
|
||||||
* situations judged instances of it, plus any suggested and awaiting a
|
* situations judged instances of it, plus any suggested and awaiting a
|
||||||
@@ -210,6 +213,8 @@ export interface RuleWrite {
|
|||||||
how_to_apply: string;
|
how_to_apply: string;
|
||||||
order_index: number;
|
order_index: number;
|
||||||
system_ids: number[];
|
system_ids: number[];
|
||||||
|
/** Replaces the set; [] unmounts the rule from every moment. */
|
||||||
|
moments: string[];
|
||||||
arose_from_id: number | null;
|
arose_from_id: number | null;
|
||||||
verify_with: string;
|
verify_with: string;
|
||||||
expires_when: string;
|
expires_when: string;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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.load(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.load(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 { computed, ref, watch, onMounted } from "vue";
|
||||||
import { useRulebooksStore } from "@/stores/rulebooks";
|
import { useRulebooksStore } from "@/stores/rulebooks";
|
||||||
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
|
||||||
|
import { useMomentsStore } from "@/stores/moments";
|
||||||
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
|
||||||
import RuleHomePicker from "@/components/rules/RuleHomePicker.vue";
|
import RuleHomePicker from "@/components/rules/RuleHomePicker.vue";
|
||||||
import type { Rule, RuleKind } from "@/api/rulebooks";
|
import type { Rule, RuleKind } from "@/api/rulebooks";
|
||||||
@@ -11,6 +12,7 @@ const emit = defineEmits<{ close: [] }>();
|
|||||||
|
|
||||||
const store = useRulebooksStore();
|
const store = useRulebooksStore();
|
||||||
const canon = useCanonicalSystemsStore();
|
const canon = useCanonicalSystemsStore();
|
||||||
|
const momentsStore = useMomentsStore();
|
||||||
const title = ref("");
|
const title = ref("");
|
||||||
const statement = ref("");
|
const statement = ref("");
|
||||||
const whenToApply = ref("");
|
const whenToApply = ref("");
|
||||||
@@ -20,6 +22,18 @@ const whenToApply = ref("");
|
|||||||
// session may quietly rewrite.
|
// session may quietly rewrite.
|
||||||
const kind = ref<RuleKind>("rule");
|
const kind = ref<RuleKind>("rule");
|
||||||
const systemIds = ref<number[]>([]);
|
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 why = ref("");
|
||||||
const howToApply = ref("");
|
const howToApply = ref("");
|
||||||
const verifyWith = ref("");
|
const verifyWith = ref("");
|
||||||
@@ -46,6 +60,26 @@ function relationLabel(kind: string, direction: "outgoing" | "incoming") {
|
|||||||
return RELATION_LABEL[kind]?.[direction] ?? kind;
|
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) {
|
function toggleSystem(id: number) {
|
||||||
const at = systemIds.value.indexOf(id);
|
const at = systemIds.value.indexOf(id);
|
||||||
if (at >= 0) systemIds.value.splice(at, 1);
|
if (at >= 0) systemIds.value.splice(at, 1);
|
||||||
@@ -85,6 +119,7 @@ async function load() {
|
|||||||
whenToApply.value = r.when_to_apply || "";
|
whenToApply.value = r.when_to_apply || "";
|
||||||
kind.value = r.kind;
|
kind.value = r.kind;
|
||||||
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
systemIds.value = (r.systems ?? []).map((sys) => sys.id);
|
||||||
|
ruleMoments.value = [...(r.moments ?? [])];
|
||||||
why.value = r.why || "";
|
why.value = r.why || "";
|
||||||
howToApply.value = r.how_to_apply || "";
|
howToApply.value = r.how_to_apply || "";
|
||||||
verifyWith.value = r.verify_with || "";
|
verifyWith.value = r.verify_with || "";
|
||||||
@@ -96,12 +131,15 @@ async function load() {
|
|||||||
whenToApply.value = "";
|
whenToApply.value = "";
|
||||||
kind.value = "rule";
|
kind.value = "rule";
|
||||||
systemIds.value = [];
|
systemIds.value = [];
|
||||||
|
ruleMoments.value = [];
|
||||||
why.value = "";
|
why.value = "";
|
||||||
howToApply.value = "";
|
howToApply.value = "";
|
||||||
verifyWith.value = "";
|
verifyWith.value = "";
|
||||||
expiresWhen.value = "";
|
expiresWhen.value = "";
|
||||||
}
|
}
|
||||||
await canon.fetchCatalog();
|
procedureDraft.value = "";
|
||||||
|
procedureError.value = "";
|
||||||
|
await Promise.all([canon.fetchCatalog(), momentsStore.load()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -117,6 +155,9 @@ async function save() {
|
|||||||
// Always sent, so clearing the last area actually clears it — the server
|
// Always sent, so clearing the last area actually clears it — the server
|
||||||
// reads a list as "these ARE the areas now".
|
// reads a list as "these ARE the areas now".
|
||||||
system_ids: systemIds.value,
|
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,
|
why: why.value,
|
||||||
how_to_apply: howToApply.value,
|
how_to_apply: howToApply.value,
|
||||||
// Always sent, including empty. The REST door maps "" to NULL, so
|
// 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.
|
its trigger, so an empty trigger leaves the rule findable by nobody.
|
||||||
</p>
|
</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">
|
<fieldset v-if="canon.catalog.length" class="areas">
|
||||||
<legend>Areas this rule is about</legend>
|
<legend>Areas this rule is about</legend>
|
||||||
<label v-for="entry in canon.catalog" :key="entry.id" class="area-opt">
|
<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 { 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); }
|
.area-opt input { width: auto; margin-top: 0.2rem; accent-color: var(--fs-accent); }
|
||||||
.trigger-missing textarea { border-color: var(--fs-warning); }
|
.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
|
/* --fs-warning-fg, not --fs-warning: the token set draws the distinction
|
||||||
between the warning HUE and warning text, and this is text. */
|
between the warning HUE and warning text, and this is text. */
|
||||||
.trigger-warning {
|
.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
|
panes already use, loaded here because the slide-over can open with no
|
||||||
pane that loads it (a link straight to ?rule=N). -->
|
pane that loads it (a link straight to ?rule=N). -->
|
||||||
<style src="@/assets/rules-shared.css" />
|
<style src="@/assets/rules-shared.css" />
|
||||||
|
<style src="@/assets/moments-shared.css" />
|
||||||
|
|||||||
@@ -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 load(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 load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data, loading, failed, load,
|
||||||
|
map: (m: MappingChange) => change("map", m),
|
||||||
|
unmap: (m: MappingChange) => change("unmap", m),
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGrou
|
|||||||
import type { User } from "@/types/auth";
|
import type { User } from "@/types/auth";
|
||||||
import PaginationBar from "@/components/PaginationBar.vue";
|
import PaginationBar from "@/components/PaginationBar.vue";
|
||||||
import TagInput from "@/components/TagInput.vue";
|
import TagInput from "@/components/TagInput.vue";
|
||||||
|
import MomentsSettings from "@/components/MomentsSettings.vue";
|
||||||
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
|
||||||
import { fetchVersion, type VersionPayload } from "@/api/version";
|
import { fetchVersion, type VersionPayload } from "@/api/version";
|
||||||
|
|
||||||
@@ -2222,6 +2223,17 @@ async function deleteUser(userId: number) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- ── Account ── -->
|
<!-- ── Account ── -->
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"name": "scribe",
|
||||||
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
|
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
|
||||||
"version": "2026.10.05.1630",
|
"version": "2026.10.05.1823",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Bryan Van Deusen"
|
"name": "Bryan Van Deusen"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
---
|
---
|
||||||
name: brainstorming
|
name: brainstorming
|
||||||
description: Use when exploring options or shaping a direction before committing — open up the solution space instead of jumping to the first idea. Triggers on "how should we approach X", "what are the options", weighing trade-offs, or any open-ended design question. Recall prior thinking first; capture the decision after.
|
description: Use when exploring options or shaping a direction before committing — open up the solution space instead of jumping to the first idea. Triggers on "how should we approach X", "what are the options", weighing trade-offs, or any open-ended design question. Recall prior thinking first; capture the decision after.
|
||||||
|
metadata:
|
||||||
|
moments: work.plan
|
||||||
---
|
---
|
||||||
|
|
||||||
# Brainstorming
|
# Brainstorming
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
---
|
---
|
||||||
name: reporting-back
|
name: reporting-back
|
||||||
description: Use when you are about to write the reply the operator will read — work finished, a task marked done, stopping on a blocker, asking them to decide or to do something, answering "where are we" / "what's next", or proposing an approach. Shapes the reply around where the work stands (which task, what changed, what needs them, what comes next) instead of the order you did things in. Triggers on reporting completion, handing off, asking a question, or summarising progress.
|
description: Use when you are about to write the reply the operator will read — work finished, a task marked done, stopping on a blocker, asking them to decide or to do something, answering "where are we" / "what's next", or proposing an approach. Shapes the reply around where the work stands (which task, what changed, what needs them, what comes next) instead of the order you did things in. Triggers on reporting completion, handing off, asking a question, or summarising progress.
|
||||||
|
metadata:
|
||||||
|
moments: reply.report
|
||||||
---
|
---
|
||||||
|
|
||||||
# Reporting back
|
# Reporting back
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
---
|
---
|
||||||
name: reusing-code
|
name: reusing-code
|
||||||
description: Use when you're about to build ANY shape — a component, control, route handler, service class, helper, test scaffold — search recorded snippets FIRST and start from the recorded shape instead of re-solving it. And before the turn that built it ends, say what it is — record the reusable as a snippet, classify the rest — so every later instance starts from it. Triggers on "write a util/helper", "I need a function that…", "let me add a component/button/field/route", having just built the first instance of anything, or the end-of-turn list of shapes to judge.
|
description: Use when you're about to build ANY shape — a component, control, route handler, service class, helper, test scaffold — search recorded snippets FIRST and start from the recorded shape instead of re-solving it. And before the turn that built it ends, say what it is — record the reusable as a snippet, classify the rest — so every later instance starts from it. Triggers on "write a util/helper", "I need a function that…", "let me add a component/button/field/route", having just built the first instance of anything, or the end-of-turn list of shapes to judge.
|
||||||
|
metadata:
|
||||||
|
moments: work.change
|
||||||
---
|
---
|
||||||
|
|
||||||
# Reusing code — the pattern library
|
# Reusing code — the pattern library
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
---
|
---
|
||||||
name: shape-accounting
|
name: shape-accounting
|
||||||
description: Use when a project's shape accounting needs attention — the pattern_coverage line from enter_project shows unclassified shapes or is missing on a forge-served project, the operator asks about coverage/accounting/canon, or you just proved a code-to-canon relationship (an audit enumerated call sites, a consolidation repointed consumers, a verify pass confirmed a helper's users). Triggers on "coverage", "accounted", "unclassified", "classify shapes", "what uses this", or finishing any consolidation.
|
description: Use when a project's shape accounting needs attention — the pattern_coverage line from enter_project shows unclassified shapes or is missing on a forge-served project, the operator asks about coverage/accounting/canon, or you just proved a code-to-canon relationship (an audit enumerated call sites, a consolidation repointed consumers, a verify pass confirmed a helper's users). Triggers on "coverage", "accounted", "unclassified", "classify shapes", "what uses this", or finishing any consolidation.
|
||||||
|
metadata:
|
||||||
|
moments: work.record
|
||||||
---
|
---
|
||||||
|
|
||||||
# Shape accounting — every shape classified against canon
|
# Shape accounting — every shape classified against canon
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
---
|
---
|
||||||
name: systematic-debugging
|
name: systematic-debugging
|
||||||
description: Use when diagnosing a bug, failure, or unexpected behavior — investigate methodically instead of guessing. Triggers on "why is this failing/breaking", a stack trace, a flaky test, or any "it should work but doesn't". On resolution, capture the issue in Scribe so it isn't re-debugged from scratch.
|
description: Use when diagnosing a bug, failure, or unexpected behavior — investigate methodically instead of guessing. Triggers on "why is this failing/breaking", a stack trace, a flaky test, or any "it should work but doesn't". On resolution, capture the issue in Scribe so it isn't re-debugged from scratch.
|
||||||
|
metadata:
|
||||||
|
moments: work.debug
|
||||||
---
|
---
|
||||||
|
|
||||||
# Systematic debugging
|
# Systematic debugging
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
---
|
---
|
||||||
name: using-scribe
|
name: using-scribe
|
||||||
description: Use at the START of every session, and before answering anything about the operator's work or starting any task — establishes the Scribe-first reflex. You hold none of the operator's rules: they arrive by retrieval when your work matches one, and what_might_apply is how you ask before a consequential act — it returns the wide net of candidates with no bar. Call enter_project when a repo/project is in scope. Then recall before acting, update over duplicate, plan in Scribe not in files.
|
description: Use at the START of every session, and before answering anything about the operator's work or starting any task — establishes the Scribe-first reflex. You hold none of the operator's rules: they arrive by retrieval when your work matches one, and what_might_apply is how you ask before a consequential act — it returns the wide net of candidates with no bar. Call enter_project when a repo/project is in scope. Then recall before acting, update over duplicate, plan in Scribe not in files.
|
||||||
|
metadata:
|
||||||
|
moments: session.start
|
||||||
---
|
---
|
||||||
|
|
||||||
# Using Scribe
|
# Using Scribe
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
---
|
---
|
||||||
name: verification
|
name: verification
|
||||||
description: Use before claiming a task is done or a change works — confirm it actually does, then record that you did. Triggers when you're about to report completion, mark a task done, or say "it works" / "fixed". Guards against declaring success on unverified work.
|
description: Use before claiming a task is done or a change works — confirm it actually does, then record that you did. Triggers when you're about to report completion, mark a task done, or say "it works" / "fixed". Guards against declaring success on unverified work.
|
||||||
|
metadata:
|
||||||
|
moments: work.verify
|
||||||
---
|
---
|
||||||
|
|
||||||
# Verification before completion
|
# Verification before completion
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
---
|
---
|
||||||
name: writing-plans
|
name: writing-plans
|
||||||
description: Use when a piece of work has an arc — several steps toward one goal, worth tracking as a unit — and you want the approach reviewable before you start. Triggers when the user asks you to plan, design an approach, or scope an effort, or when work is about to sprawl across several steps. Not for single-step work. The plan lives in a Scribe milestone (via start_planning), not a local file.
|
description: Use when a piece of work has an arc — several steps toward one goal, worth tracking as a unit — and you want the approach reviewable before you start. Triggers when the user asks you to plan, design an approach, or scope an effort, or when work is about to sprawl across several steps. Not for single-step work. The plan lives in a Scribe milestone (via start_planning), not a local file.
|
||||||
|
metadata:
|
||||||
|
moments: work.plan
|
||||||
---
|
---
|
||||||
|
|
||||||
# Writing plans
|
# Writing plans
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from scribe.mcp._context import current_user_id
|
|||||||
from scribe.services import access as access_svc
|
from scribe.services import access as access_svc
|
||||||
from scribe.services import dedup as dedup_svc
|
from scribe.services import dedup as dedup_svc
|
||||||
from scribe.services import knowledge as knowledge_svc
|
from scribe.services import knowledge as knowledge_svc
|
||||||
|
from scribe.services import moment_actions
|
||||||
|
from scribe.services import moments as moments_svc
|
||||||
from scribe.services import notes as notes_svc
|
from scribe.services import notes as notes_svc
|
||||||
from scribe.services import systems as systems_svc
|
from scribe.services import systems as systems_svc
|
||||||
from scribe.services import trash as trash_svc
|
from scribe.services import trash as trash_svc
|
||||||
@@ -53,7 +55,8 @@ async def list_processes(
|
|||||||
|
|
||||||
async def create_process(
|
async def create_process(
|
||||||
title: str, body: str, tags: list[str] | None = None,
|
title: str, body: str, tags: list[str] | None = None,
|
||||||
system_ids: list[int] | None = None, force: bool = False,
|
system_ids: list[int] | None = None, moments: list[str] | None = None,
|
||||||
|
force: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a stored process (a reusable saved prompt).
|
"""Create a stored process (a reusable saved prompt).
|
||||||
|
|
||||||
@@ -79,6 +82,12 @@ async def create_process(
|
|||||||
system_ids: Systems (subsystems/areas) to file this process under, so
|
system_ids: Systems (subsystems/areas) to file this process under, so
|
||||||
an area-scoped read finds it. A process is a note, so it has always
|
an area-scoped read finds it. A process is a note, so it has always
|
||||||
been taggable in the data model; neither door offered it (#4249).
|
been taggable in the data model; neither door offered it (#4249).
|
||||||
|
moments: The moments from list_moments this procedure is FOR — a
|
||||||
|
release procedure is a deliver, a review procedure a verify.
|
||||||
|
Loading the process then reaches each one, as well as its own
|
||||||
|
`skill.scribe-proc-<slug>`, so a rule mounted on work.deliver
|
||||||
|
arrives when the release procedure is loaded, before any of its
|
||||||
|
steps run.
|
||||||
force: Bypass the near-duplicate gate. By default, if a title- or
|
force: Bypass the near-duplicate gate. By default, if a title- or
|
||||||
meaning-similar process already exists, creation is BLOCKED and the
|
meaning-similar process already exists, creation is BLOCKED and the
|
||||||
existing one's id is returned so you update it instead. Set true
|
existing one's id is returned so you update it instead. Set true
|
||||||
@@ -102,12 +111,33 @@ async def create_process(
|
|||||||
)
|
)
|
||||||
if dup is not None:
|
if dup is not None:
|
||||||
return dedup_svc.duplicate_response(dup, "process")
|
return dedup_svc.duplicate_response(dup, "process")
|
||||||
|
declared = moments_svc.require_moments(moments)
|
||||||
note = await notes_svc.create_note(
|
note = await notes_svc.create_note(
|
||||||
uid, title=title.strip(), body=body, note_type="process", tags=tags,
|
uid, title=title.strip(), body=body, note_type="process", tags=tags,
|
||||||
|
data=_with_moments(None, declared),
|
||||||
)
|
)
|
||||||
if system_ids:
|
if system_ids:
|
||||||
await systems_svc.set_record_systems(uid, note.id, system_ids)
|
await systems_svc.set_record_systems(uid, note.id, system_ids)
|
||||||
return await moment_delivery.attach_moment_rules(uid, "create_process", {}, note.to_dict())
|
return await moment_delivery.attach_moment_rules(uid, "create_process", {}, _process_dict(note))
|
||||||
|
|
||||||
|
|
||||||
|
def _with_moments(data: dict | None, declared: list[str] | None) -> dict | None:
|
||||||
|
"""`data` with its moments replaced by `declared` (None leaves them)."""
|
||||||
|
out = dict(data or {})
|
||||||
|
if declared is None:
|
||||||
|
return out or None
|
||||||
|
if declared:
|
||||||
|
out[moment_actions.PROCESS_MOMENTS_FIELD] = declared
|
||||||
|
else:
|
||||||
|
out.pop(moment_actions.PROCESS_MOMENTS_FIELD, None)
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
|
def _process_dict(note) -> dict:
|
||||||
|
"""A process as the tools return it: the note, plus the moments it declares."""
|
||||||
|
out = note.to_dict()
|
||||||
|
out["moments"] = moment_actions.declared_moments(note.data)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def get_process(name_or_id: str, project_id: int = 0) -> dict:
|
async def get_process(name_or_id: str, project_id: int = 0) -> dict:
|
||||||
@@ -144,7 +174,7 @@ async def get_process(name_or_id: str, project_id: int = 0) -> dict:
|
|||||||
note, candidates = await notes_svc.resolve_process(uid, name_or_id)
|
note, candidates = await notes_svc.resolve_process(uid, name_or_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
raise ValueError(f"process {name_or_id!r} not found")
|
raise ValueError(f"process {name_or_id!r} not found")
|
||||||
out = note.to_dict()
|
out = _process_dict(note)
|
||||||
if candidates:
|
if candidates:
|
||||||
out["other_matches"] = candidates
|
out["other_matches"] = candidates
|
||||||
out.update(await access_svc.describe_provenance(uid, note))
|
out.update(await access_svc.describe_provenance(uid, note))
|
||||||
@@ -162,13 +192,18 @@ async def get_process(name_or_id: str, project_id: int = 0) -> dict:
|
|||||||
|
|
||||||
async def update_process(process_id: int, title: str = "", body: str = "",
|
async def update_process(process_id: int, title: str = "", body: str = "",
|
||||||
tags: list[str] | None = None,
|
tags: list[str] | None = None,
|
||||||
system_ids: list[int] | None = None) -> dict:
|
system_ids: list[int] | None = None,
|
||||||
|
moments: list[str] | None = None) -> dict:
|
||||||
"""Update a stored process. Only provided fields change — empty title/body
|
"""Update a stored process. Only provided fields change — empty title/body
|
||||||
leave that field unchanged; pass tags to replace the tag set.
|
leave that field unchanged; pass tags to replace the tag set.
|
||||||
|
|
||||||
`system_ids` replaces the Systems this process is filed under: None leaves
|
`system_ids` replaces the Systems this process is filed under: None leaves
|
||||||
them alone, a list (including `[]`) replaces them.
|
them alone, a list (including `[]`) replaces them.
|
||||||
|
|
||||||
|
`moments` replaces the moments the procedure is for (see create_process)
|
||||||
|
the same way: None leaves them, [] clears them. The change reaches the
|
||||||
|
next load of its skill — the skill file itself does not carry them.
|
||||||
|
|
||||||
Editing another user's process requires an editor or admin share from them; a
|
Editing another user's process requires an editor or admin share from them; a
|
||||||
read-only share is not enough and says so rather than claiming not-found.
|
read-only share is not enough and says so rather than claiming not-found.
|
||||||
"""
|
"""
|
||||||
@@ -189,6 +224,9 @@ async def update_process(process_id: int, title: str = "", body: str = "",
|
|||||||
fields["body"] = body
|
fields["body"] = body
|
||||||
if tags is not None:
|
if tags is not None:
|
||||||
fields["tags"] = tags
|
fields["tags"] = tags
|
||||||
|
declared = moments_svc.require_moments(moments)
|
||||||
|
if declared is not None:
|
||||||
|
fields["data"] = _with_moments(note.data, declared)
|
||||||
# As the owner — update_note is owner-scoped and the write is authorised above.
|
# As the owner — update_note is owner-scoped and the write is authorised above.
|
||||||
updated = await notes_svc.update_note(note.user_id, process_id, **fields)
|
updated = await notes_svc.update_note(note.user_id, process_id, **fields)
|
||||||
# The CALLER, not the owner. `update_note` above is owner-scoped because
|
# The CALLER, not the owner. `update_note` above is owner-scoped because
|
||||||
@@ -199,7 +237,7 @@ async def update_process(process_id: int, title: str = "", body: str = "",
|
|||||||
await systems_svc.set_record_systems(uid, process_id, system_ids)
|
await systems_svc.set_record_systems(uid, process_id, system_ids)
|
||||||
if updated is None:
|
if updated is None:
|
||||||
raise ValueError(f"process {process_id} not found")
|
raise ValueError(f"process {process_id} not found")
|
||||||
out = updated.to_dict()
|
out = _process_dict(updated)
|
||||||
out.update(await access_svc.describe_provenance(uid, updated))
|
out.update(await access_svc.describe_provenance(uid, updated))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
much as from the MCP tool, and the only difference is the `actor` recorded.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from quart import Blueprint, jsonify, request
|
from quart import Blueprint, jsonify, request
|
||||||
|
|
||||||
from scribe.auth import get_current_user_id, login_required
|
from scribe.auth import get_current_user_id, login_required
|
||||||
from scribe.services import moment_actions as moment_actions_svc
|
from scribe.services import moment_actions as moment_actions_svc
|
||||||
from scribe.services import moments as moments_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
|
from scribe.services.retrieval_tuning import set_dial, current_settings, tuning_history
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -115,16 +118,39 @@ async def moments_route():
|
|||||||
actions that reach each one on this install.
|
actions that reach each one on this install.
|
||||||
|
|
||||||
The same payload `list_moments` returns, from the same services, so the
|
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 = 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)
|
return jsonify(out)
|
||||||
|
|
||||||
|
|
||||||
async def _mapping_change(change):
|
async def _mapping_change(change):
|
||||||
"""map/unmap from the browser: the MCP tools' service, recorded as human."""
|
"""map/unmap from the browser: the MCP tools' service, recorded as human.
|
||||||
data = await request.get_json()
|
|
||||||
|
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):
|
if not isinstance(data, dict):
|
||||||
return jsonify({"error": "Expected a JSON object"}), 400
|
return jsonify({"error": "Expected a JSON object"}), 400
|
||||||
if not data.get("tool") or not data.get("moment"):
|
if not data.get("tool") or not data.get("moment"):
|
||||||
|
|||||||
@@ -63,6 +63,33 @@ COMMAND_TOOLS = frozenset({"bash"})
|
|||||||
# procedures' own.
|
# procedures' own.
|
||||||
SKILL_TOOL, SKILL_FIELD = "skill", "skill"
|
SKILL_TOOL, SKILL_FIELD = "skill", "skill"
|
||||||
|
|
||||||
|
# A procedure also reaches the moment it is FOR (step 5): loading the
|
||||||
|
# reporting procedure is a report as surely as the reply that follows it.
|
||||||
|
#
|
||||||
|
# The product's own skills declare theirs in their SKILL.md frontmatter
|
||||||
|
# (`metadata: moments:`), and the declaration ships HERE as defaults, because
|
||||||
|
# the server never sees the plugin's files. `test_skill_moments` holds the two
|
||||||
|
# together. The plugin's name qualifies the skill, as the harness names it.
|
||||||
|
BUNDLED_SKILL_PLUGIN = "scribe"
|
||||||
|
BUNDLED_SKILL_MOMENTS: dict[str, tuple[str, ...]] = {
|
||||||
|
"using-scribe": ("session.start",),
|
||||||
|
"brainstorming": ("work.plan",),
|
||||||
|
"writing-plans": ("work.plan",),
|
||||||
|
"reusing-code": ("work.change",),
|
||||||
|
"systematic-debugging": ("work.debug",),
|
||||||
|
"verification": ("work.verify",),
|
||||||
|
"shape-accounting": ("work.record",),
|
||||||
|
"reporting-back": ("reply.report",),
|
||||||
|
}
|
||||||
|
|
||||||
|
# A stored process arrives as the skill `scribe-proc-<slug>`
|
||||||
|
# (scribe_sync_processes.sh). Its moments are the process's own field, read
|
||||||
|
# when it loads — not copied into the stub, which is written once a session
|
||||||
|
# and would go on firing a moment its process no longer declares.
|
||||||
|
PROCESS_SKILL_PREFIX = "scribe-proc-"
|
||||||
|
PROCESS_MOMENTS_FIELD = "moments"
|
||||||
|
PROCESS = "process"
|
||||||
|
|
||||||
_SEGMENT_SPLIT = re.compile(r"&&|\|\||[;|\n]")
|
_SEGMENT_SPLIT = re.compile(r"&&|\|\||[;|\n]")
|
||||||
_ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=\S*\s+")
|
_ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=\S*\s+")
|
||||||
|
|
||||||
@@ -92,6 +119,9 @@ def _defaults() -> tuple[Action, ...]:
|
|||||||
on("work.plan", "EnterPlanMode")
|
on("work.plan", "EnterPlanMode")
|
||||||
on("work.plan", "ExitPlanMode")
|
on("work.plan", "ExitPlanMode")
|
||||||
on("reply.ask", "AskUserQuestion")
|
on("reply.ask", "AskUserQuestion")
|
||||||
|
for skill, reached in BUNDLED_SKILL_MOMENTS.items():
|
||||||
|
for moment in reached:
|
||||||
|
on(moment, "Skill", f"{SKILL_FIELD}={BUNDLED_SKILL_PLUGIN}:{skill}")
|
||||||
|
|
||||||
# Scribe's own tools — every install has these, so their moments ship.
|
# Scribe's own tools — every install has these, so their moments ship.
|
||||||
on("work.start", "update_task", "status=in_progress")
|
on("work.start", "update_task", "status=in_progress")
|
||||||
@@ -201,12 +231,38 @@ def effective_actions(mappings: Iterable) -> list[tuple[Action, str]]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def resolve(tool: str, tool_input: dict | None, mappings: Iterable = ()) -> list[dict]:
|
def skill_name(tool: str, tool_input: dict | None) -> str:
|
||||||
|
"""The procedure a skill-loader call loads, lowercased; "" for any other call."""
|
||||||
|
if tool_key(tool) != SKILL_TOOL:
|
||||||
|
return ""
|
||||||
|
return str((tool_input or {}).get(SKILL_FIELD) or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def declared_moments(data: dict | None) -> list[str]:
|
||||||
|
"""The moments a stored process declares — the valid ones, in its order.
|
||||||
|
|
||||||
|
Read defensively: the field is written through require_moments, but a
|
||||||
|
moment the catalog later drops must stop firing, not break the load.
|
||||||
|
"""
|
||||||
|
raw = (data or {}).get(PROCESS_MOMENTS_FIELD) or []
|
||||||
|
if isinstance(raw, str):
|
||||||
|
raw = [raw]
|
||||||
|
return [m for m in dict.fromkeys(str(n).strip().lower() for n in raw)
|
||||||
|
if catalog.is_moment(m)]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
tool: str, tool_input: dict | None, mappings: Iterable = (),
|
||||||
|
declared: Iterable[str] = (),
|
||||||
|
) -> list[dict]:
|
||||||
"""Every moment this call reaches, each with the action that reached it.
|
"""Every moment this call reaches, each with the action that reached it.
|
||||||
|
|
||||||
One entry per moment, in catalog order. When two actions reach the same
|
One entry per moment, in catalog order. When two actions reach the same
|
||||||
moment the more specific one — the longer match — is the one named, since
|
moment the more specific one — the longer match — is the one named, since
|
||||||
"reached by `git push`" says more than "reached by Bash".
|
"reached by `git push`" says more than "reached by Bash".
|
||||||
|
|
||||||
|
`declared` are the moments the procedure this call loads says it is for
|
||||||
|
(a stored process's own field); each is reached by the load itself.
|
||||||
"""
|
"""
|
||||||
best: dict[str, dict] = {}
|
best: dict[str, dict] = {}
|
||||||
for action, via in effective_actions(mappings):
|
for action, via in effective_actions(mappings):
|
||||||
@@ -219,12 +275,15 @@ def resolve(tool: str, tool_input: dict | None, mappings: Iterable = ()) -> list
|
|||||||
"match": action.match, "via": via,
|
"match": action.match, "via": via,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tool_key(tool) == SKILL_TOOL:
|
name = skill_name(tool, tool_input)
|
||||||
name = str((tool_input or {}).get(SKILL_FIELD) or "").strip().lower()
|
if name:
|
||||||
moment = catalog.SKILL_PREFIX + name
|
moment = catalog.SKILL_PREFIX + name
|
||||||
if name and catalog.is_moment(moment):
|
if catalog.is_moment(moment):
|
||||||
best[moment] = {"moment": moment, "tool": tool, "match": f"skill={name}",
|
best[moment] = {"moment": moment, "tool": tool,
|
||||||
"via": DEFAULT}
|
"match": f"{SKILL_FIELD}={name}", "via": DEFAULT}
|
||||||
|
for moment in declared:
|
||||||
|
best.setdefault(moment, {"moment": moment, "tool": tool,
|
||||||
|
"match": f"{SKILL_FIELD}={name}", "via": PROCESS})
|
||||||
|
|
||||||
order = {name: i for i, name in enumerate(catalog.MOMENTS)}
|
order = {name: i for i, name in enumerate(catalog.MOMENTS)}
|
||||||
return sorted(best.values(), key=lambda h: (order.get(h["moment"], len(order)), h["moment"]))
|
return sorted(best.values(), key=lambda h: (order.get(h["moment"], len(order)), h["moment"]))
|
||||||
@@ -286,7 +345,35 @@ async def moments_for(user_id: int, tool: str, tool_input: dict | None) -> list[
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("moment mappings unreadable; using the defaults", exc_info=True)
|
logger.warning("moment mappings unreadable; using the defaults", exc_info=True)
|
||||||
mappings = []
|
mappings = []
|
||||||
return resolve(tool, tool_input, mappings)
|
name = skill_name(tool, tool_input)
|
||||||
|
declared = (await process_moments(user_id, name)
|
||||||
|
if name.startswith(PROCESS_SKILL_PREFIX) else [])
|
||||||
|
return resolve(tool, tool_input, mappings, declared)
|
||||||
|
|
||||||
|
|
||||||
|
async def process_moments(user_id: int, skill: str) -> list[str]:
|
||||||
|
"""The moments the stored process behind `scribe-proc-<slug>` declares.
|
||||||
|
|
||||||
|
The slug is resolved through the same manifest the sync script wrote the
|
||||||
|
skill from, so the two agree on which process a slug names. Fails open to
|
||||||
|
none: a process that cannot be read still loads, it just reaches no more
|
||||||
|
than its own `skill.<name>`.
|
||||||
|
"""
|
||||||
|
from scribe.services import notes as notes_svc
|
||||||
|
from scribe.services import plugin_context
|
||||||
|
|
||||||
|
slug = skill[len(PROCESS_SKILL_PREFIX):]
|
||||||
|
try:
|
||||||
|
manifest = await plugin_context.build_process_manifest(user_id)
|
||||||
|
entry = next((p for p in manifest.get("processes", []) if p.get("slug") == slug), None)
|
||||||
|
if entry is None:
|
||||||
|
return []
|
||||||
|
loaded = await notes_svc.get_note_for_user(user_id, int(entry["id"]))
|
||||||
|
note = loaded[0] if loaded else None
|
||||||
|
return declared_moments(note.data if note is not None else None)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("process moments for %s unreadable", skill, exc_info=True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
async def _find(session, user_id: int, tool: str, match: str, moment: str):
|
async def _find(session, user_id: int, tool: str, match: str, moment: str):
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from scribe.services import moment_actions
|
from scribe.services import moment_actions
|
||||||
from scribe.services import moments as catalog
|
|
||||||
from scribe.services import retrieval_pipeline as rp
|
from scribe.services import retrieval_pipeline as rp
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -39,8 +38,10 @@ async def reachable_tools(user_id: int) -> list[str]:
|
|||||||
What the plugin's catch-all hook reads once per session window, so the
|
What the plugin's catch-all hook reads once per session window, so the
|
||||||
calls that cannot reach a mounted rule — most of them, and every one on
|
calls that cannot reach a mounted rule — most of them, and every one on
|
||||||
an install that has mounted nothing — never leave the machine. An action
|
an install that has mounted nothing — never leave the machine. An action
|
||||||
counts only when its moment carries a mount; the skill loader counts when
|
counts only when its moment carries a mount. The skill loader counts
|
||||||
any `skill.<name>` moment does.
|
whenever anything is mounted: a stored process declares its own moments,
|
||||||
|
which only the load itself can resolve, and a load is rare enough that
|
||||||
|
asking costs nothing.
|
||||||
"""
|
"""
|
||||||
from scribe.services import rulebooks
|
from scribe.services import rulebooks
|
||||||
|
|
||||||
@@ -53,8 +54,7 @@ async def reachable_tools(user_id: int) -> list[str]:
|
|||||||
for action, _via in moment_actions.effective_actions(mappings)
|
for action, _via in moment_actions.effective_actions(mappings)
|
||||||
if action.moment in mounted
|
if action.moment in mounted
|
||||||
}
|
}
|
||||||
if any(name.startswith(catalog.SKILL_PREFIX) for name in mounted):
|
keys.add(moment_actions.SKILL_TOOL)
|
||||||
keys.add(moment_actions.SKILL_TOOL)
|
|
||||||
return sorted(keys)
|
return sorted(keys)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ from typing import Any
|
|||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
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 import async_session
|
||||||
from scribe.models.base import iso
|
from scribe.models.base import iso
|
||||||
@@ -985,6 +986,94 @@ async def _system_usage(user_id: int | None, since) -> dict:
|
|||||||
return block
|
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(
|
async def retrieval_summary(
|
||||||
user_id: int | None, *, days: int = 30, near_miss_samples: int = 0,
|
user_id: int | None, *, days: int = 30, near_miss_samples: int = 0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
@@ -1583,6 +1672,7 @@ async def retrieval_summary(
|
|||||||
rule_usage.update(_coverage((rule_complete or {}).get("*"), since))
|
rule_usage.update(_coverage((rule_complete or {}).get("*"), since))
|
||||||
out["rule_usage"] = rule_usage
|
out["rule_usage"] = rule_usage
|
||||||
out["system_usage"] = await _system_usage(user_id, since)
|
out["system_usage"] = await _system_usage(user_id, since)
|
||||||
|
out["moment_usage"] = await moment_usage(user_id, since)
|
||||||
|
|
||||||
# ── Judged lines (#4772) ─────────────────────────────────────────────
|
# ── Judged lines (#4772) ─────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -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
|
tool whose moments carry nothing. A superset is the safe direction — the
|
||||||
delivery read still scopes by project.
|
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.models.rulebook import rule_moments as rule_moments_t
|
||||||
from scribe.services.rule_scope import joined_to_homes, rule_home
|
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:
|
async with async_session() as session:
|
||||||
rows = (await session.execute(
|
rows = (await session.execute(
|
||||||
joined_to_homes(
|
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)
|
.select_from(rule_moments_t)
|
||||||
.join(Rule, Rule.id == rule_moments_t.c.rule_id)
|
.join(Rule, Rule.id == rule_moments_t.c.rule_id)
|
||||||
)
|
)
|
||||||
.where(Rule.deleted_at.is_(None), home)
|
.where(Rule.deleted_at.is_(None), home)
|
||||||
)).scalars().all()
|
.group_by(rule_moments_t.c.moment)
|
||||||
return set(rows)
|
)).all()
|
||||||
|
return {moment: int(n) for moment, n in rows}
|
||||||
|
|
||||||
|
|
||||||
async def list_rule_systems(rule_ids: list[int]) -> dict[int, list[dict]]:
|
async def list_rule_systems(rule_ids: list[int]) -> dict[int, list[dict]]:
|
||||||
|
|||||||
@@ -199,3 +199,64 @@ async def test_the_delivery_lookup_answers_by_home(world):
|
|||||||
# The plugin's "can anything arrive" answer spans every home.
|
# The plugin's "can anything arrive" answer spans every home.
|
||||||
assert await rulebooks_svc.mounted_moments(uid) >= {"work.finish", "reply.report", "work.deliver"}
|
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"}
|
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"]
|
||||||
|
|||||||
@@ -166,6 +166,88 @@ async def test_delete_process_refuses_a_plain_note():
|
|||||||
mock_delete.assert_not_awaited()
|
mock_delete.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
# ── the moments a procedure is for (milestone 458 step 5) ────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_process_stores_the_moments_it_is_for():
|
||||||
|
created = fake_note(title="Release", note_type="process",
|
||||||
|
data={"moments": ["work.deliver"]})
|
||||||
|
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
|
||||||
|
AsyncMock(return_value=None)), \
|
||||||
|
patch("scribe.services.notes.create_note",
|
||||||
|
AsyncMock(return_value=created)) as mock_create:
|
||||||
|
from scribe.mcp.tools.processes import create_process
|
||||||
|
out = await create_process(title="Release", body="steps",
|
||||||
|
moments=["Work.Deliver", "work.deliver"])
|
||||||
|
assert mock_create.await_args.kwargs["data"] == {"moments": ["work.deliver"]}
|
||||||
|
assert out["moments"] == ["work.deliver"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_process_refuses_an_unknown_moment_before_writing():
|
||||||
|
"""A typo'd moment would be stored and never fire — a procedure that looks
|
||||||
|
attached and is not."""
|
||||||
|
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
|
||||||
|
AsyncMock(return_value=None)), \
|
||||||
|
patch("scribe.services.notes.create_note", AsyncMock()) as mock_create:
|
||||||
|
from scribe.mcp.tools.processes import create_process
|
||||||
|
with pytest.raises(ValueError, match="unknown moment"):
|
||||||
|
await create_process(title="Release", body="steps", moments=["work.shipped"])
|
||||||
|
mock_create.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_process_without_moments_stores_no_data():
|
||||||
|
created = fake_note(title="Release", note_type="process")
|
||||||
|
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
|
||||||
|
AsyncMock(return_value=None)), \
|
||||||
|
patch("scribe.services.notes.create_note",
|
||||||
|
AsyncMock(return_value=created)) as mock_create:
|
||||||
|
from scribe.mcp.tools.processes import create_process
|
||||||
|
out = await create_process(title="Release", body="steps")
|
||||||
|
assert mock_create.await_args.kwargs["data"] is None
|
||||||
|
assert out["moments"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def _update(moments, data):
|
||||||
|
proc = fake_note(id=5, title="Release", note_type="process", data=data)
|
||||||
|
with patch("scribe.services.notes.get_note_for_user",
|
||||||
|
AsyncMock(return_value=(proc, "owner"))), \
|
||||||
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=True)), \
|
||||||
|
patch("scribe.services.notes.update_note",
|
||||||
|
AsyncMock(return_value=proc)) as mock_update:
|
||||||
|
from scribe.mcp.tools.processes import update_process
|
||||||
|
await update_process(process_id=5, moments=moments)
|
||||||
|
return mock_update.await_args.kwargs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_process_replaces_moments_and_keeps_the_rest_of_data():
|
||||||
|
sent = await _update(["work.verify"], {"moments": ["work.deliver"], "other": 1})
|
||||||
|
assert sent["data"] == {"moments": ["work.verify"], "other": 1}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_process_empty_moments_clears_them():
|
||||||
|
assert (await _update([], {"moments": ["work.deliver"]}))["data"] is None
|
||||||
|
assert (await _update([], {"moments": ["work.deliver"], "other": 1}))["data"] == {"other": 1}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_process_without_moments_leaves_data_alone():
|
||||||
|
assert "data" not in await _update(None, {"moments": ["work.deliver"]})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_process_says_which_moments_it_is_for():
|
||||||
|
note = fake_note(id=7, title="Release", note_type="process",
|
||||||
|
data={"moments": ["work.deliver"]})
|
||||||
|
with patch("scribe.services.notes.resolve_process", AsyncMock(return_value=(note, []))):
|
||||||
|
from scribe.mcp.tools.processes import get_process
|
||||||
|
out = await get_process("release")
|
||||||
|
assert out["moments"] == ["work.deliver"]
|
||||||
|
|
||||||
|
|
||||||
def test_register_attaches_every_tool_in_the_module():
|
def test_register_attaches_every_tool_in_the_module():
|
||||||
"""Derived from the module rather than listed: a tool written but never
|
"""Derived from the module rather than listed: a tool written but never
|
||||||
registered is invisible to an agent, and nothing else would notice."""
|
registered is invisible to an agent, and nothing else would notice."""
|
||||||
|
|||||||
@@ -109,9 +109,22 @@ def test_a_whole_tool_mapping_ignores_its_arguments():
|
|||||||
|
|
||||||
|
|
||||||
def test_loading_a_procedure_reaches_its_own_moment():
|
def test_loading_a_procedure_reaches_its_own_moment():
|
||||||
|
assert _reached("Skill", {"skill": "release-notes"}) == ["skill.release-notes"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_bundled_skill_also_reaches_the_moment_it_is_for():
|
||||||
|
"""Loading the planning procedure IS planning: a rule mounted on work.plan
|
||||||
|
arrives on the load, not only on the plan it goes on to open."""
|
||||||
assert _reached("Skill", {"skill": "scribe:writing-plans"}) == [
|
assert _reached("Skill", {"skill": "scribe:writing-plans"}) == [
|
||||||
"skill.scribe:writing-plans",
|
"work.plan", "skill.scribe:writing-plans",
|
||||||
]
|
]
|
||||||
|
assert _reached("Skill", {"skill": "scribe:reporting-back"})[0] == "reply.report"
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_the_products_own_skill_reaches_a_bundled_default():
|
||||||
|
"""Another plugin's `verification` skill is not the product's procedure;
|
||||||
|
the plugin's name is what makes the declaration ours."""
|
||||||
|
assert _reached("Skill", {"skill": "other:verification"}) == ["skill.other:verification"]
|
||||||
|
|
||||||
|
|
||||||
def test_a_procedure_name_that_is_not_a_moment_reaches_nothing():
|
def test_a_procedure_name_that_is_not_a_moment_reaches_nothing():
|
||||||
@@ -119,6 +132,79 @@ def test_a_procedure_name_that_is_not_a_moment_reaches_nothing():
|
|||||||
assert _reached("Skill", {}) == []
|
assert _reached("Skill", {}) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── a stored process's own moments ───────────────────────────────────────
|
||||||
|
|
||||||
|
def test_a_process_declares_moments_its_load_reaches():
|
||||||
|
hits = ma.resolve("Skill", {"skill": "scribe-proc-release"}, [], ["work.deliver"])
|
||||||
|
assert [(h["moment"], h["via"]) for h in hits] == [
|
||||||
|
("work.deliver", ma.PROCESS), ("skill.scribe-proc-release", ma.DEFAULT),
|
||||||
|
]
|
||||||
|
assert hits[0]["match"] == "skill=scribe-proc-release"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_declared_moment_another_action_reaches_is_named_once():
|
||||||
|
rows = [_row("Skill", "skill=scribe-proc-release", "work.deliver")]
|
||||||
|
hits = ma.resolve("Skill", {"skill": "scribe-proc-release"}, rows, ["work.deliver"])
|
||||||
|
assert [h["moment"] for h in hits].count("work.deliver") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_declared_moments_only_reach_a_skill_load():
|
||||||
|
hits = ma.resolve("Bash", {"command": "ls"}, [], ["work.deliver"])
|
||||||
|
assert [h["moment"] for h in hits] == ["work.run"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("data, expected", [
|
||||||
|
(None, []),
|
||||||
|
({}, []),
|
||||||
|
({"moments": ["work.deliver", "Work.Deliver", "reply.report"]}, ["work.deliver", "reply.report"]),
|
||||||
|
({"moments": "work.verify"}, ["work.verify"]),
|
||||||
|
({"moments": ["work.finished", "skill.two words", "work.debug"]}, ["work.debug"]),
|
||||||
|
])
|
||||||
|
def test_a_process_field_is_read_defensively(data, expected):
|
||||||
|
"""A moment the catalog no longer has stops firing; it does not break the load."""
|
||||||
|
assert ma.declared_moments(data) == expected
|
||||||
|
|
||||||
|
|
||||||
|
async def test_only_a_process_skill_asks_for_its_process():
|
||||||
|
asked = AsyncMock(return_value=["work.deliver"])
|
||||||
|
with patch.object(ma, "list_mappings", AsyncMock(return_value=[])), \
|
||||||
|
patch.object(ma, "process_moments", asked):
|
||||||
|
reached = await ma.moments_for(1, "Skill", {"skill": "scribe-proc-release"})
|
||||||
|
await ma.moments_for(1, "Skill", {"skill": "scribe:verification"})
|
||||||
|
await ma.moments_for(1, "Bash", {"command": "git push"})
|
||||||
|
assert "work.deliver" in [h["moment"] for h in reached]
|
||||||
|
asked.assert_awaited_once_with(1, "scribe-proc-release")
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_moments(manifest, note=None, *, raises=False):
|
||||||
|
from scribe.services import notes as notes_svc
|
||||||
|
from scribe.services import plugin_context
|
||||||
|
|
||||||
|
built = AsyncMock(side_effect=RuntimeError("down")) if raises else AsyncMock(return_value=manifest)
|
||||||
|
loader = AsyncMock(return_value=(note, None) if note is not None else None)
|
||||||
|
with patch.object(plugin_context, "build_process_manifest", built), \
|
||||||
|
patch.object(notes_svc, "get_note_for_user", loader):
|
||||||
|
return await ma.process_moments(1, "scribe-proc-release"), loader
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_process_slug_resolves_through_the_sync_manifest():
|
||||||
|
"""The same manifest the sync script wrote the skill from, so the slug
|
||||||
|
names the same process here as it did on disk."""
|
||||||
|
note = SimpleNamespace(data={"moments": ["work.deliver"]})
|
||||||
|
found, loader = await _process_moments(
|
||||||
|
{"processes": [{"id": 7, "slug": "other"}, {"id": 9, "slug": "release"}]}, note)
|
||||||
|
assert found == ["work.deliver"]
|
||||||
|
loader.assert_awaited_once_with(1, 9)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_unknown_or_unreadable_process_reaches_nothing_more():
|
||||||
|
found, loader = await _process_moments({"processes": [{"id": 7, "slug": "other"}]})
|
||||||
|
assert found == []
|
||||||
|
loader.assert_not_awaited()
|
||||||
|
found, _ = await _process_moments({}, raises=True)
|
||||||
|
assert found == []
|
||||||
|
|
||||||
|
|
||||||
# ── an install's own ─────────────────────────────────────────────────────
|
# ── an install's own ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_an_install_mapping_adds_a_moment():
|
def test_an_install_mapping_adds_a_moment():
|
||||||
|
|||||||
@@ -194,16 +194,23 @@ async def test_an_install_with_nothing_mounted_keeps_the_hook_off_the_wire():
|
|||||||
|
|
||||||
|
|
||||||
async def test_only_the_tools_whose_moments_carry_a_mount_are_listed():
|
async def test_only_the_tools_whose_moments_carry_a_mount_are_listed():
|
||||||
assert await _reachable({"work.deliver"}) == ["bash"]
|
assert await _reachable({"work.deliver"}) == ["bash", "skill"]
|
||||||
assert await _reachable({"work.finish"}) == ["update_milestone", "update_task"]
|
assert await _reachable({"work.finish"}) == ["skill", "update_milestone", "update_task"]
|
||||||
assert await _reachable({"skill.release"}) == ["skill"]
|
assert await _reachable({"skill.release"}) == ["skill"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_skill_loader_counts_whenever_anything_is_mounted():
|
||||||
|
"""A stored process declares its own moments, resolved only when it loads
|
||||||
|
— so a mount on any moment may be reached by loading a procedure."""
|
||||||
|
assert "skill" in await _reachable({"work.debug"})
|
||||||
|
|
||||||
|
|
||||||
async def test_an_installs_own_mapping_and_removal_both_count():
|
async def test_an_installs_own_mapping_and_removal_both_count():
|
||||||
own = SimpleNamespace(tool="mcp__deploy__ship", match="", moment="work.deliver", effect="add")
|
own = SimpleNamespace(tool="mcp__deploy__ship", match="", moment="work.deliver", effect="add")
|
||||||
assert "ship" in await _reachable({"work.deliver"}, [own])
|
assert "ship" in await _reachable({"work.deliver"}, [own])
|
||||||
gone = SimpleNamespace(tool="AskUserQuestion", match="", moment="reply.ask", effect="remove")
|
gone = SimpleNamespace(tool="AskUserQuestion", match="", moment="reply.ask", effect="remove")
|
||||||
assert await _reachable({"reply.ask"}, [gone]) == []
|
assert "askuserquestion" in await _reachable({"reply.ask"})
|
||||||
|
assert "askuserquestion" not in await _reachable({"reply.ask"}, [gone])
|
||||||
|
|
||||||
|
|
||||||
# ── the plugin routes ───────────────────────────────────────────────────
|
# ── the plugin routes ───────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
# And the setting is left alone too: rewriting the same value would bump
|
||||||
# whatever timestamp the row carries for no reason.
|
# whatever timestamp the row carries for no reason.
|
||||||
setter.assert_not_called()
|
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
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""The bundled skills declare the moments they are for (milestone 458 step 5).
|
||||||
|
|
||||||
|
Loading the reporting procedure is a report; loading the planning procedure
|
||||||
|
is planning. Each SKILL.md says which moment it is for in its frontmatter
|
||||||
|
(`metadata: moments:`), where an author editing the skill sees it — but the
|
||||||
|
server that resolves a skill load to its moments never sees the plugin's
|
||||||
|
files, so the same declaration ships as defaults in
|
||||||
|
`moment_actions.BUNDLED_SKILL_MOMENTS`. Two copies drift unless something
|
||||||
|
holds them together; this does, in both directions, and every skill has to
|
||||||
|
make the choice rather than inherit silence.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from scribe.services import moment_actions as ma
|
||||||
|
from scribe.services import moments
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SKILLS = ROOT / "plugin" / "skills"
|
||||||
|
MANIFEST = ROOT / "plugin" / ".claude-plugin" / "plugin.json"
|
||||||
|
|
||||||
|
_FRONT = re.compile(r"\A---\n(.*?)\n---\n", re.S)
|
||||||
|
_METADATA = re.compile(r"^metadata:[ \t]*\n((?:[ \t]+\S.*(?:\n|\Z))*)", re.M)
|
||||||
|
_MOMENTS = re.compile(r"^[ \t]+moments:[ \t]*(.*)$", re.M)
|
||||||
|
|
||||||
|
|
||||||
|
def declared(text: str) -> tuple[str, ...]:
|
||||||
|
"""The moments a SKILL.md's frontmatter declares; () when it declares none.
|
||||||
|
|
||||||
|
Agent Skills metadata maps strings to strings, so several moments are one
|
||||||
|
comma-separated value.
|
||||||
|
"""
|
||||||
|
front = _FRONT.match(text)
|
||||||
|
meta = _METADATA.search(front.group(1)) if front else None
|
||||||
|
line = _MOMENTS.search(meta.group(1)) if meta else None
|
||||||
|
if not line:
|
||||||
|
return ()
|
||||||
|
return tuple(m.strip() for m in line.group(1).split(",") if m.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _on_disk() -> dict[str, tuple[str, ...]]:
|
||||||
|
return {p.parent.name: declared(p.read_text()) for p in sorted(SKILLS.glob("*/SKILL.md"))}
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_reader_finds_a_declaration_and_only_in_the_frontmatter():
|
||||||
|
"""The guard below is only as good as this reader, so it must be able to
|
||||||
|
say no: a declaration in the body, or none at all, reads as nothing."""
|
||||||
|
assert declared(
|
||||||
|
"---\nname: x\ndescription: y\nmetadata:\n moments: work.plan, reply.report\n---\nbody\n"
|
||||||
|
) == ("work.plan", "reply.report")
|
||||||
|
assert declared("---\nname: x\ndescription: y\n---\nmetadata:\n moments: work.plan\n") == ()
|
||||||
|
assert declared("---\nname: x\nmetadata:\n author: someone\n---\n") == ()
|
||||||
|
assert declared("no frontmatter\n") == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_bundled_skill_declares_the_moments_it_is_for():
|
||||||
|
silent = [name for name, found in _on_disk().items() if not found]
|
||||||
|
assert not silent, (
|
||||||
|
f"{silent} declare no moment. Add `metadata:` / ` moments: <moment>` to "
|
||||||
|
f"the frontmatter (list_moments has the catalog) and the same entry to "
|
||||||
|
f"moment_actions.BUNDLED_SKILL_MOMENTS"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_frontmatter_and_the_shipped_defaults_agree():
|
||||||
|
assert _on_disk() == ma.BUNDLED_SKILL_MOMENTS, (
|
||||||
|
"a SKILL.md's `metadata: moments:` and moment_actions.BUNDLED_SKILL_MOMENTS "
|
||||||
|
"disagree — the server reads only the latter, so change both together"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_declared_moment_is_in_the_catalog():
|
||||||
|
bad = {name: found for name, found in _on_disk().items()
|
||||||
|
if any(m not in moments.MOMENTS for m in found)}
|
||||||
|
assert not bad, bad
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_defaults_qualify_skills_by_the_plugins_own_name():
|
||||||
|
"""The harness names a plugin's skill `<plugin>:<skill>`, so the defaults
|
||||||
|
match only while this agrees with the manifest."""
|
||||||
|
assert json.loads(MANIFEST.read_text())["name"] == ma.BUNDLED_SKILL_PLUGIN
|
||||||
Reference in New Issue
Block a user