feat(500): Settings shows the shipped reply shapes, what adjusts each, and how often each arrives
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Successful in 1m19s
CI & Build / Python tests (push) Successful in 1m58s
CI & Build / Build & push image (push) Successful in 39s

Settings > General > Reply shapes. One menu row per shape (core, completion,
asks, plan): its title and when it arrives, how many of the operator's
preferences adjust it, and its deliveries over 30 days (in full / as a
reminder, "—" when the counts cannot be read). A verdict line leads. Opening a
row shows the shipped text, read-only because it is product, and the
preferences mounted on its moment; each opens in the rule editor, and "Adjust
this" opens a new preference already mounted on the shape's moment with a
starting trigger.

The rule editor takes an optional preset (kind, moments, trigger) and, opened
outside the Rules view, asks where a new record lives instead of dropping it.
GET /api/retrieval/reply-shapes now returns reply_shapes.overview: the
catalog plus mounted preferences (the same lookup that delivers them) and
counts from the reply_shape delivery rows. #5497 (step 5 of milestone 500).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 16:19:45 -04:00
co-authored by Claude Opus 5.5
parent b00dc7c4c2
commit 69dddb7551
8 changed files with 505 additions and 16 deletions
+47
View File
@@ -0,0 +1,47 @@
import { apiGet } from "@/api/client";
/**
* The reply shapes Scribe ships (milestone 500) — `GET /api/retrieval/reply-shapes`.
* The same catalog the `list_reply_shapes` MCP tool returns, plus what only
* the operator's view needs: the preferences mounted on each shape's moment,
* and how often each shape was delivered.
*/
export interface ShapePreference {
id: number;
title: string;
statement: string;
}
/** How often a shape went out: whole, or as its one-line reminder. */
export interface ShapeDeliveries {
full: number;
pointer: number;
}
export interface ReplyShape {
key: string;
title: string;
/** The moment it rides — a preference mounted here adjusts it. */
moment: string;
/** What is happening at that moment. */
means: string;
/** When the shape arrives, in words. */
delivered: string;
/** The shipped text. Product, so read-only. */
text: string;
preferences: ShapePreference[];
/** null when the counts could not be read — unknown, not zero. */
deliveries: ShapeDeliveries | null;
}
export interface ReplyShapesOverview {
shapes: ReplyShape[];
total: number;
days: number;
deliveries_failed: boolean;
}
export async function getReplyShapes(days?: number): Promise<ReplyShapesOverview> {
const q = days ? `?days=${days}` : "";
return apiGet<ReplyShapesOverview>(`/api/retrieval/reply-shapes${q}`);
}
+9
View File
@@ -54,6 +54,15 @@ export interface RulebookTopic {
*/
export type RuleKind = "rule" | "preference";
/** A new record opened from somewhere that already knows what it is for —
* "Adjust this" on a reply shape opens a preference mounted on that shape's
* moment (milestone 500 step 5). The editor applies it once, when creating. */
export interface RulePreset {
kind?: RuleKind;
moments?: string[];
whenToApply?: string;
}
export interface Rule {
id: number;
topic_id: number | null;
@@ -0,0 +1,226 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { apiErrorMessage } from "@/api/client";
import { getReplyShapes, type ReplyShape, type ReplyShapesOverview } from "@/api/replyShapes";
import type { RulePreset } from "@/api/rulebooks";
import RuleEditorSlideOver from "@/components/rules/RuleEditorSlideOver.vue";
/**
* The reply shapes Scribe ships, and the operator's adjustments to each
* (milestone 500 step 5). A shape is product, so its text is read-only here;
* what the operator owns is the preferences mounted on the shape's moment,
* which arrive beside it and win where they differ. "Adjust this" opens the
* preference editor already mounted there.
*/
const data = ref<ReplyShapesOverview | null>(null);
const loading = ref(true);
const error = ref("");
const open = ref<string | null>(null);
async function load() {
loading.value = true;
error.value = "";
try {
data.value = await getReplyShapes();
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not load the reply shapes");
} finally {
loading.value = false;
}
}
const shapes = computed(() => data.value?.shapes ?? []);
const adjusted = computed(() => shapes.value.filter((s) => s.preferences.length).length);
const delivered = computed(() =>
shapes.value.reduce((n, s) => n + (s.deliveries ? s.deliveries.full + s.deliveries.pointer : 0), 0),
);
// One line answering "what do my replies get shaped by?".
const verdict = computed(() => {
if (!data.value) return "";
const n = shapes.value.length;
const parts = [`${n} ${n === 1 ? "shape ships" : "shapes ship"} with Scribe`];
parts.push(adjusted.value
? `${adjusted.value} adjusted by your preferences`
: "none adjusted — the defaults stand");
if (!data.value.deliveries_failed) parts.push(`delivered ${delivered.value} times in ${data.value.days} days`);
return parts.join(" · ");
});
function deliveryLabel(s: ReplyShape): string {
if (!s.deliveries) return "—";
const { full, pointer } = s.deliveries;
if (!full && !pointer) return "not delivered";
return `${full} in full · ${pointer} as reminder`;
}
function toggle(key: string) {
open.value = open.value === key ? null : key;
}
// ── The editor: an existing preference, or a new one mounted here ─────────
const editingId = ref<number | null>(null);
const preset = ref<RulePreset | null>(null);
const editorOpen = computed(() => editingId.value !== null || preset.value !== null);
function editPreference(id: number) {
preset.value = null;
editingId.value = id;
}
function adjust(s: ReplyShape) {
editingId.value = null;
preset.value = {
kind: "preference",
moments: [s.moment],
// A starting trigger in the moment's own words; the operator sharpens it.
whenToApply: `When ${s.means}.`,
};
}
function closeEditor() {
editingId.value = null;
preset.value = null;
void load();
}
onMounted(load);
</script>
<template>
<div class="reply-shapes">
<p v-if="loading && !data" class="state">Loading…</p>
<p v-else-if="error" class="state state-error" role="alert">
{{ error }}
<button type="button" class="btn-text" @click="load">Retry</button>
</p>
<template v-else-if="data">
<p class="verdict">{{ verdict }}</p>
<p v-if="data.deliveries_failed" class="state state-error" role="alert">
Delivery counts could not be read, so they show as “—”.
<button type="button" class="btn-text" @click="load">Retry</button>
</p>
<ul class="shape-list">
<li v-for="s in shapes" :key="s.key" :class="['shape-row', { open: open === s.key }]">
<button
type="button"
class="shape-head"
:aria-expanded="open === s.key"
:aria-controls="`shape-${s.key}`"
@click="toggle(s.key)"
>
<span class="shape-name">
<strong>{{ s.title }}</strong>
<span class="shape-when">{{ s.delivered }}</span>
</span>
<span class="shape-stats">
<span :class="{ 'stat-none': !s.preferences.length }">
{{ s.preferences.length
? `${s.preferences.length} ${s.preferences.length === 1 ? "preference" : "preferences"}`
: "default" }}
</span>
<span :class="{ 'stat-none': deliveryLabel(s) === 'not delivered' }">{{ deliveryLabel(s) }}</span>
<span class="chevron" aria-hidden="true">{{ open === s.key ? "▾" : "▸" }}</span>
</span>
</button>
<div v-if="open === s.key" :id="`shape-${s.key}`" class="shape-body">
<h4 class="body-label">Ships with Scribe · at <code>{{ s.moment }}</code></h4>
<div class="shape-text">{{ s.text }}</div>
<h4 class="body-label">Your adjustments</h4>
<ul v-if="s.preferences.length" class="pref-list">
<li v-for="p in s.preferences" :key="p.id">
<button type="button" class="pref-row" @click="editPreference(p.id)">
<span class="pref-title">{{ p.title }}</span>
<span class="chevron" aria-hidden="true">›</span>
</button>
</li>
</ul>
<p v-else class="stat-none">None — this shape arrives as shipped.</p>
<button type="button" class="btn-primary adjust" @click="adjust(s)">Adjust this</button>
</div>
</li>
</ul>
</template>
<RuleEditorSlideOver
v-if="editorOpen"
:rule-id="editingId"
:topic-id="null"
:preset="preset"
@close="closeEditor"
/>
</div>
</template>
<style scoped>
.reply-shapes { display: flex; flex-direction: column; gap: var(--fs-space-2); }
.verdict { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); }
.state { margin: 0; font-size: var(--fs-size-body-sm); color: var(--fs-text-secondary); }
.state-error { color: var(--fs-error); }
.shape-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--fs-space-2); }
.shape-row {
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
/* The whole row is the tap target (a settings row, not prose with a link). */
.shape-head {
width: 100%;
display: flex; align-items: baseline; gap: var(--fs-space-3);
padding: var(--fs-space-3);
background: none; border: 0; border-radius: var(--fs-radius-md);
color: inherit; font: inherit; text-align: left; cursor: pointer;
}
.shape-head:focus-visible { outline: 2px solid var(--fs-focus-ring); outline-offset: 2px; }
.shape-name { display: flex; flex-direction: column; gap: var(--fs-space-1); min-width: 0; }
.shape-name strong { font-size: var(--fs-size-body-sm); color: var(--fs-text-primary); }
.shape-when {
font-size: var(--fs-size-tiny); color: var(--fs-text-tertiary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.shape-stats {
margin-left: auto;
display: flex; align-items: baseline; gap: var(--fs-space-3);
font-size: var(--fs-size-tiny); color: var(--fs-text-secondary);
font-variant-numeric: tabular-nums; white-space: nowrap;
}
.stat-none { color: var(--fs-text-tertiary); font-style: italic; }
.chevron { color: var(--fs-text-tertiary); }
.shape-body {
display: flex; flex-direction: column; align-items: flex-start; gap: var(--fs-space-2);
padding: 0 var(--fs-space-3) var(--fs-space-3);
}
.body-label {
margin: var(--fs-space-2) 0 0;
font-size: var(--fs-size-tiny); font-weight: normal;
text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny);
color: var(--fs-text-tertiary);
}
.body-label code { font-family: var(--fs-font-mono); text-transform: none; }
/* Product text, shown as it reaches a session. Read-only by design. */
.shape-text {
width: 100%;
padding: var(--fs-space-2) var(--fs-space-3);
border-left: 2px solid var(--fs-border-color);
font-size: var(--fs-size-body-sm); line-height: var(--fs-leading-body);
color: var(--fs-text-secondary); white-space: pre-wrap;
}
.pref-list { list-style: none; margin: 0; padding: 0; width: 100%; display: flex; flex-direction: column; gap: var(--fs-space-1); }
.pref-row {
width: 100%;
display: flex; align-items: baseline; gap: var(--fs-space-2);
padding: var(--fs-space-1) var(--fs-space-2);
background: none; border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-sm);
color: var(--fs-text-primary); font: inherit; font-size: var(--fs-size-body-sm);
text-align: left; cursor: pointer;
}
.pref-row:focus-visible { outline: 2px solid var(--fs-focus-ring); outline-offset: 2px; }
.pref-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pref-row .chevron { margin-left: auto; }
.adjust { margin-top: var(--fs-space-1); }
</style>
@@ -5,9 +5,14 @@ import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
import { useMomentsStore } from "@/stores/moments";
import RuleHistoryPanel from "@/components/rules/RuleHistoryPanel.vue";
import RuleHomePicker from "@/components/rules/RuleHomePicker.vue";
import type { Rule, RuleKind } from "@/api/rulebooks";
import { apiErrorMessage } from "@/api/client";
import { listRulebooks, listTopics, type Rule, type RuleKind, type RulePreset } from "@/api/rulebooks";
const props = defineProps<{ ruleId: number | null; topicId: number | null }>();
const props = defineProps<{
ruleId: number | null;
topicId: number | null;
preset?: RulePreset | null;
}>();
const emit = defineEmits<{ close: [] }>();
const store = useRulebooksStore();
@@ -128,10 +133,10 @@ async function load() {
} else {
title.value = "";
statement.value = "";
whenToApply.value = "";
kind.value = "rule";
whenToApply.value = props.preset?.whenToApply ?? "";
kind.value = props.preset?.kind ?? "rule";
systemIds.value = [];
ruleMoments.value = [];
ruleMoments.value = [...(props.preset?.moments ?? [])];
why.value = "";
howToApply.value = "";
verifyWith.value = "";
@@ -139,7 +144,31 @@ async function load() {
}
procedureDraft.value = "";
procedureError.value = "";
await Promise.all([canon.fetchCatalog(), momentsStore.load()]);
await Promise.all([canon.fetchCatalog(), momentsStore.load(), loadHomes()]);
}
// ── Where a new record lives, when the opener did not say ──────────────────
// The rules view opens the editor inside a topic; Settings does not, so a new
// record opened there asks for its home. A rulebook topic is global — it
// applies in every project, as a reply shape does.
const homeChoices = ref<{ id: number; label: string }[]>([]);
const chosenTopic = ref<number | null>(null);
const homeError = ref("");
const needsHome = computed(() => isCreating.value && props.topicId === null);
async function loadHomes() {
if (!needsHome.value || homeChoices.value.length) return;
try {
const books = await listRulebooks();
const perBook = await Promise.all(books.map(async (rb) => {
const ts = await listTopics(rb.id);
return ts.map((t) => ({ id: t.id, label: `${rb.title} › ${t.title}` }));
}));
homeChoices.value = perBook.flat();
if (homeChoices.value.length === 1) chosenTopic.value = homeChoices.value[0].id;
} catch (e: unknown) {
homeError.value = apiErrorMessage(e, "Could not load where this could live");
}
}
async function save() {
@@ -166,8 +195,14 @@ async function save() {
verify_with: verifyWith.value,
expires_when: expiresWhen.value,
};
if (isCreating.value && props.topicId !== null) {
await store.createRule(props.topicId, fields);
const home = props.topicId ?? chosenTopic.value;
if (isCreating.value && home === null) {
// Written but homeless: keep the draft open rather than lose it on close.
homeError.value = "Choose where it lives before saving.";
return;
}
if (isCreating.value && home !== null) {
await store.createRule(home, fields);
} else if (props.ruleId !== null) {
await store.updateRule(props.ruleId, fields);
}
@@ -201,6 +236,14 @@ watch(() => props.ruleId, load);
<button v-if="!isCreating" class="trash" @click="remove" aria-label="Delete">🗑</button>
<button class="close" @click="save" aria-label="Close">×</button>
</header>
<label v-if="needsHome">
Where it lives
<select v-model="chosenTopic" class="fs-input">
<option :value="null" disabled>Choose a rulebook topic</option>
<option v-for="h in homeChoices" :key="h.id" :value="h.id">{{ h.label }}</option>
</select>
</label>
<p v-if="homeError" class="home-error" role="alert">{{ homeError }}</p>
<label>
Title
<input v-model="title" placeholder="e.g. dev is home" />
@@ -421,6 +464,7 @@ watch(() => props.ruleId, load);
one index everywhere (tests/test_frontend_shared_styles.py). -->
<style src="@/assets/rules-shared.css" />
<style scoped>
.home-error { margin: 0 0 var(--fs-space-2); color: var(--fs-error); font-size: var(--fs-size-body-sm); }
.kind { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-md); padding: var(--fs-space-3); margin: var(--fs-space-3) 0; }
.kind legend { font-size: var(--fs-size-tiny); text-transform: uppercase; letter-spacing: var(--fs-tracking-tiny); color: var(--fs-text-tertiary); padding: 0 var(--fs-space-2); }
.kind-opt { display: flex; align-items: flex-start; gap: var(--fs-space-2); margin-bottom: var(--fs-space-2); font-size: var(--fs-size-body-sm); line-height: var(--fs-leading-body); }
+10
View File
@@ -10,6 +10,7 @@ import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
import MomentsSettings from "@/components/MomentsSettings.vue";
import ReplyShapesSettings from "@/components/ReplyShapesSettings.vue";
import { fmtDate, fmtLogStamp } from "@/utils/dateFormat";
import { fetchVersion, type VersionPayload } from "@/api/version";
@@ -2250,6 +2251,15 @@ async function deleteUser(userId: number) {
<MomentsSettings />
</section>
<section class="settings-section full-width">
<h2>Reply shapes</h2>
<p class="section-desc">
The default shape of each kind of reply, delivered to a session when it is due.
Adjust one with a preference; it arrives beside the shape and wins.
</p>
<ReplyShapesSettings />
</section>
</div>
<!-- ── Account ── -->
+9 -3
View File
@@ -149,10 +149,16 @@ async def moments_route():
async def reply_shapes_route():
"""The default reply shapes and the moment each rides (milestone 500).
The same payload `list_reply_shapes` returns, from the same service, so
the Settings view and the session cannot show different defaults.
`list_reply_shapes`' payload from the same service, so the Settings view
and the session cannot show different defaults — plus what only the
operator's view needs (step 5): per shape, the preferences mounted on its
moment and its deliveries over `days` (default 30, 1–90).
"""
return jsonify(shapes_svc.catalog())
try:
days = min(90, max(1, int(request.args.get("days") or shapes_svc.OVERVIEW_DAYS)))
except ValueError:
days = shapes_svc.OVERVIEW_DAYS
return jsonify(await shapes_svc.overview(get_current_user_id(), days=days))
async def _mapping_change(change):
+74
View File
@@ -287,3 +287,77 @@ async def record_delivery(user_id: int | None, forms: dict[str, str], *, via: st
await session.commit()
except Exception: # noqa: BLE001 - observation never breaks the observed
logger.debug("reply shape delivery not recorded", exc_info=True)
# ── The operator's view (milestone 500 step 5) ──────────────────────────
# The window the Settings view counts deliveries over — the same 30 days the
# moments panel beside it reads, so the two count the same stretch of work.
OVERVIEW_DAYS = 30
async def delivery_counts(user_id: int, *, days: int = OVERVIEW_DAYS) -> dict[str, dict[str, int]]:
"""How often each shape went out, in each form, over the last `days`.
Read from the `reply_shape` rows `record_delivery` writes — one per
delivery, naming each shape and whether it went whole or as its reminder.
A row whose details do not parse is skipped rather than guessed at.
"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.app_log import AppLog
since = datetime.now(timezone.utc) - timedelta(days=days)
counts: dict[str, dict[str, int]] = {k: {FULL: 0, POINTER: 0} for k in SHAPES}
async with async_session() as session:
rows = (await session.execute(
select(AppLog.details).where(
AppLog.category == "plugin", AppLog.action == "reply_shape",
AppLog.user_id == user_id, AppLog.created_at >= since,
)
)).scalars().all()
for raw in rows:
try:
forms = json.loads(raw or "{}").get("shapes") or {}
except (ValueError, AttributeError):
continue
for key, form in forms.items() if isinstance(forms, dict) else ():
if key in counts and form in counts[key]:
counts[key][form] += 1
return counts
async def overview(user_id: int, *, days: int = OVERVIEW_DAYS) -> dict:
"""The Settings view's read: every shape, the operator's preferences that
adjust it, and how often it was delivered.
A preference adjusts a shape by being MOUNTED on the shape's moment — the
same lookup that delivers it beside the shape — so what this lists is
exactly what arrives with it. Global homes only: a project's own
preferences are that project's, and its rules tab lists them.
The counts are telemetry and fail open to `deliveries_failed` with "—" in
the view; the preference lookup is the substance and is allowed to fail
the request, so the view shows an error instead of "no preferences".
"""
from scribe.services import rulebooks
data = catalog()
try:
counts: dict[str, dict[str, int]] | None = await delivery_counts(user_id, days=days)
except Exception: # noqa: BLE001 - a missing count is shown as unknown
logger.warning("reply shape delivery counts failed", exc_info=True)
counts = None
for row in data["shapes"]:
mounted = await rulebooks.rules_on_moments(user_id, [row["moment"]])
row["preferences"] = [
{"id": rule.id, "title": rule.title, "statement": rule.statement}
for rule, _at in mounted if rule.kind == "preference"
]
row["deliveries"] = counts.get(row["key"]) if counts is not None else None
data["days"] = days
data["deliveries_failed"] = counts is None
return data
+78 -5
View File
@@ -139,18 +139,91 @@ def test_both_doors_read_one_catalog():
assert routes.shapes_svc is reply_shapes
async def test_the_route_returns_the_catalog():
async def test_the_route_returns_the_operators_overview_with_a_bounded_window():
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from quart import Quart, g
from scribe.routes import retrieval as routes
view = AsyncMock(return_value={"shapes": [], "total": 0})
app = Quart(__name__)
async with app.test_request_context("/api/retrieval/reply-shapes"):
g.user = SimpleNamespace(id=7)
resp = await routes.reply_shapes_route.__wrapped__()
assert await resp.get_json() == reply_shapes.catalog()
for raw, days in (("", 30), ("7", 7), ("900", 90), ("x", 30)):
async with app.test_request_context("/api/retrieval/reply-shapes", query_string={"days": raw}):
g.user = SimpleNamespace(id=7)
with patch.object(routes.shapes_svc, "overview", view):
resp = await routes.reply_shapes_route.__wrapped__()
assert await resp.get_json() == {"shapes": [], "total": 0}
assert view.await_args.kwargs == {"days": days}
# ── the operator's view (milestone 500 step 5) ──────────────────────────
async def test_the_overview_lists_the_preferences_mounted_on_each_shapes_moment():
"""What adjusts a shape is what is mounted beside it — and only
preferences: a rule on the same moment binds, it does not reshape."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
pref = SimpleNamespace(id=173, title="Problem first", statement="…", kind="preference")
rule = SimpleNamespace(id=11, title="Definition of done", statement="…", kind="rule")
async def mounted(user_id, moments, project_id=None):
return [(pref, "reply.report"), (rule, "reply.report")] if moments == ["reply.report"] else []
counts = {k: {"full": 0, "pointer": 0} for k in reply_shapes.SHAPES}
counts["core"] = {"full": 2, "pointer": 9}
with patch("scribe.services.rulebooks.rules_on_moments", mounted), \
patch.object(reply_shapes, "delivery_counts", AsyncMock(return_value=counts)):
data = await reply_shapes.overview(7, days=30)
rows = {r["key"]: r for r in data["shapes"]}
assert rows["core"]["preferences"] == [{"id": 173, "title": "Problem first", "statement": "…"}]
assert rows["core"]["deliveries"] == {"full": 2, "pointer": 9}
assert rows["completion"]["preferences"] == []
assert data["days"] == 30 and data["deliveries_failed"] is False
async def test_counts_that_fail_read_as_unknown_not_zero():
from unittest.mock import AsyncMock, patch
async def mounted(user_id, moments, project_id=None):
return []
with patch("scribe.services.rulebooks.rules_on_moments", mounted), \
patch.object(reply_shapes, "delivery_counts", AsyncMock(side_effect=RuntimeError("db"))):
data = await reply_shapes.overview(7)
assert data["deliveries_failed"] is True
assert all(r["deliveries"] is None for r in data["shapes"])
async def test_delivery_counts_tally_each_shape_by_form_and_skip_what_does_not_parse():
import json
from unittest.mock import MagicMock, patch
rows = [json.dumps({"shapes": {"core": "full"}, "via": "turn"}),
json.dumps({"shapes": {"core": "pointer", "completion": "full"}, "via": "hook"}),
json.dumps({"shapes": {"core": "pointer", "bogus": "full"}, "via": "turn"}),
"not json", None]
result = MagicMock()
result.scalars.return_value.all.return_value = rows
class _Session:
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def execute(self, _stmt):
return result
with patch("scribe.models.async_session", lambda: _Session()):
counts = await reply_shapes.delivery_counts(7, days=30)
assert counts["core"] == {"full": 1, "pointer": 2}
assert counts["completion"] == {"full": 1, "pointer": 0}
assert "bogus" not in counts
# ── delivery (milestone 500 step 3) ─────────────────────────────────────