feat(rulings): system_usage_events is read back — per-System counts and a telemetry block (#4769)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / Python tests (push) Successful in 1m44s
CI & Build / Build & push image (push) Successful in 36s

#4769 "Rulings are counted where someone will read them": milestone 444
step 4 wrote system_usage_events and nothing read it.

- retrieval_telemetry gains a `system_usage` block: surfacings and opens by
  source, distinct counts, and `by_system` naming the areas most shown.
  There is deliberately no pull-through ratio, because rulings travel in full
  in the line and opens are the exception.
- usage_for_systems (one GROUP BY) adds `usage` to the REST Systems list and
  detail, and to MCP get_system. MCP list_systems is unchanged.
- The Systems UI shows a "rulings shown N×" chip.
- rulings_pre_tool, rulings_write_path and mcp_get_system are now declared
  registry points; the registry guard covers their recorders.
- The Systems store merges a PATCH reply instead of replacing the row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 08:49:20 -04:00
co-authored by Claude Opus 5.5
parent 1b973ebd13
commit 0a1bb68808
12 changed files with 457 additions and 4 deletions
+8
View File
@@ -1,5 +1,6 @@
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
import type { CanonicalMatch } from "@/api/canonicalSystems";
import type { RecordUsage } from "@/types/usage";
export interface System {
id: number;
@@ -21,6 +22,13 @@ export interface System {
*/
path_patterns: string[];
open_issue_count: number;
/**
* How often this area's rulings were shown to a session because its files
* were touched (`surfaced_count`), and how often it was opened (#4769).
* Present on the list and detail reads; optional because a System returned
* by a create or update carries none.
*/
usage?: RecordUsage;
created_at: string | null;
updated_at: string | null;
}
@@ -7,6 +7,7 @@ import { getProjectIssues } from "@/api/systems";
import type { System, TaskLike } from "@/api/systems";
import type { CanonicalMatch } from "@/api/canonicalSystems";
import { apiErrorMessage } from "@/api/client";
import { fmtDate } from "@/utils/dateFormat";
import { Pencil, Trash2, Archive, ArchiveRestore } from "lucide-vue-next";
const props = defineProps<{ projectId: number }>();
@@ -71,6 +72,25 @@ function areaName(system: System): string | null {
return canon.byId(system.canonical_id)?.name ?? null;
}
/** How many times this area's rulings reached a session (#4769). Not the
* UsageBadge: its "N/M used" reads opens over showings, and rulings are
* delivered in full in the line, so an unopened System is the arm working,
* not dead weight. */
function rulingsShown(system: System): number {
return system.usage?.surfaced_count ?? 0;
}
function rulingsTitle(system: System): string {
const u = system.usage;
if (!u) return "";
const last = u.last_surfaced_at ? ` Last shown ${fmtDate(u.last_surfaced_at)}.` : "";
return (
`This area's rulings were shown in full to a session ${u.surfaced_count}×, ` +
`when a command or edit touched its files.${last} ` +
`Opened with get_system ${u.pull_count}×.`
);
}
async function load() {
error.value = null;
try {
@@ -471,6 +491,11 @@ async function confirmDelete() {
class="area-chip"
:title="`Filed under the shared area “${areaName(system)}” — records and rules about this area line up across projects.`"
>{{ areaName(system) }}</span>
<span
v-if="rulingsShown(system)"
class="usage-tag"
:title="rulingsTitle(system)"
>rulings shown {{ rulingsShown(system) }}×</span>
</div>
<p v-if="system.description" class="system-description">{{ system.description }}</p>
<ul v-if="system.path_patterns.length" class="system-paths" aria-label="Files">
+4 -1
View File
@@ -39,7 +39,10 @@ export const useSystemsStore = defineStore("systems", () => {
const list = systemsByProject.value[projectId];
if (list) {
const idx = list.findIndex((s) => s.id === systemId);
if (idx >= 0) list[idx] = system;
// Merged, not replaced: the PATCH reply is the bare System, and the
// list's read-side decorations (issue count, rulings usage) would
// otherwise vanish from the row until the next reload.
if (idx >= 0) list[idx] = { ...list[idx], ...system };
}
return system;
}