M6: working memory + the PO→QA trigger + analytics — V1 complete

Working memory (Memory module's first real code):
- MemoryEntry (schema "memory", vector(384), InitialMemory migration); TeamMemory implements
  the SharedKernel ITeamMemory seam (embed-and-store on write, cosine recall on read);
  GET /api/memory/search. HashingTextEmbedder promoted to SharedKernel (pure, deterministic;
  swapped for ONNX/BYOK embedders later behind ITextEmbedder).
- Written on approval: Governance's approve stores an Approval/Correction entry per decision.
- Read at assembly: the executor recalls the team's top-3 relevant entries; the prompt gains
  a "# Team memory" section (treated as data, not instructions).

The single V1 event trigger:
- IAgentDispatcher (SharedKernel) implemented by Assembler's AgentRunDispatcher (shared by
  the API and triggers). OrgBoard's QaHandoffTrigger: a task hitting done creates a QA task
  (provenance parent, assigned to the QA agent) and dispatches a run for the team's QA AI
  seat. Guardrails: Test/Review tasks never re-trigger (no self-cascade) and a task hands
  off at most once. Audited as handoff.triggered.

Analytics — the V1 verdict view:
- IBoardStats (SharedKernel) implemented by OrgBoard; GET /api/governance/analytics returns
  approval rate, avg edit distance, per-agent metrics + edit-distance trend, tasks done.
- UI: /analytics — stat cards, per-agent table, recharts edit-distance trend per agent.

Verified: build green; ArchitectureTests 8/8; IntegrationTests 42/42 incl. the M6 acceptance
end to end — a dev marks a story done → Quill wakes via the handoff (QA task with provenance,
assigned to the agent) → drafts a test plan that waits in review → approve records the second
agent's edit distance → analytics show approval rate 100%, avg edit distance > 0, and trends
for BOTH Aria and Quill; memory written on Aria's corrected approval is recalled into her next
prompt; the guardrails hold. Client build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-10 12:07:35 +03:30
parent 21cfc35581
commit fe7a5c481e
28 changed files with 1187 additions and 24 deletions
+2
View File
@@ -1,5 +1,6 @@
import { Navigate, Route, Routes } from 'react-router'
import { Toaster } from '@/components/ui/sonner'
import { AnalyticsPage } from '@/pages/AnalyticsPage'
import { BoardPage } from '@/pages/BoardPage'
import { LoginPage } from '@/pages/LoginPage'
import { ReviewsPage } from '@/pages/ReviewsPage'
@@ -16,6 +17,7 @@ export default function App() {
<Route path="/" element={token ? <BoardPage /> : <Navigate to="/login" replace />} />
<Route path="/seats" element={token ? <SeatsPage /> : <Navigate to="/login" replace />} />
<Route path="/reviews" element={token ? <ReviewsPage /> : <Navigate to="/login" replace />} />
<Route path="/analytics" element={token ? <AnalyticsPage /> : <Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster richColors position="top-right" />
+2 -1
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react'
import { Link, useLocation } from 'react-router'
import { Bot, Inbox, type LucideIcon, LayoutDashboard, LogOut, Network, ShieldCheck } from 'lucide-react'
import { Bot, ChartColumn, Inbox, type LucideIcon, LayoutDashboard, LogOut, Network, ShieldCheck } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
@@ -29,6 +29,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavItem icon={LayoutDashboard} label="Board" to="/" />
<NavItem icon={Bot} label="AI seats" to="/seats" />
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
<NavItem icon={ChartColumn} label="Analytics" to="/analytics" />
<NavItem icon={Inbox} label="Cartable" muted />
<NavItem icon={Network} label="Org chart" muted />
</nav>
+184
View File
@@ -0,0 +1,184 @@
import { useCallback, useEffect, useState } from 'react'
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { api } from '@/lib/api'
import { useAuth } from '@/store/auth'
interface EditDistancePoint {
decidedAtUtc: string
distance: number
}
interface AgentAnalytics {
agentId: string
name: string
reviews: number
approvalRate: number | null
avgEditDistance: number | null
trend: EditDistancePoint[]
}
interface Analytics {
tasksDone: number
pendingReviews: number
decided: number
approved: number
sentBack: number
approvalRate: number | null
avgEditDistance: number | null
agents: AgentAnalytics[]
}
const LINE_COLORS = ['var(--color-seat-ai)', 'var(--color-teal-500, #14b8a6)', '#f59e0b', '#64748b']
export function AnalyticsPage() {
const organizationId = useAuth((s) => s.organizationId)
const [data, setData] = useState<Analytics | null>(null)
const load = useCallback(async () => {
if (!organizationId) return
try {
setData(await api.get<Analytics>(`/api/governance/analytics?organizationId=${organizationId}`))
} catch (err) {
toast.error((err as Error).message)
}
}, [organizationId])
useEffect(() => {
void load()
}, [load])
return (
<AppShell>
<div className="mx-auto max-w-4xl p-6">
<div className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight">Analytics</h1>
<p className="text-sm text-muted-foreground">
The bet, measured: human edit distance low and falling means the agents are earning trust.
</p>
</div>
{data === null ? (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
) : (
<>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Stat label="Approval rate" value={formatPercent(data.approvalRate)} />
<Stat label="Avg edit distance" value={formatDistance(data.avgEditDistance)} />
<Stat label="Tasks done" value={String(data.tasksDone)} />
<Stat label="Pending reviews" value={String(data.pendingReviews)} />
</div>
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-base">Edit distance per agent</CardTitle>
</CardHeader>
<CardContent>
{data.agents.every((a) => a.trend.length === 0) ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No approvals yet approve agent work to start the trend.
</p>
) : (
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={mergeTrends(data.agents)}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
<XAxis dataKey="time" tick={{ fontSize: 11 }} />
<YAxis domain={[0, 1]} tick={{ fontSize: 11 }} />
<Tooltip />
<Legend />
{data.agents.map((agent, i) => (
<Line
key={agent.agentId}
type="monotone"
dataKey={agent.name}
stroke={LINE_COLORS[i % LINE_COLORS.length]}
connectNulls
dot
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-base">Per agent</CardTitle>
</CardHeader>
<CardContent>
{data.agents.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">No agent activity yet.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 font-medium">Agent</th>
<th className="py-2 font-medium">Reviews</th>
<th className="py-2 font-medium">Approval rate</th>
<th className="py-2 font-medium">Avg edit distance</th>
</tr>
</thead>
<tbody>
{data.agents.map((agent) => (
<tr key={agent.agentId} className="border-b last:border-0">
<td className="py-2 font-medium">{agent.name}</td>
<td className="py-2">{agent.reviews}</td>
<td className="py-2">{formatPercent(agent.approvalRate)}</td>
<td className="py-2">{formatDistance(agent.avgEditDistance)}</td>
</tr>
))}
</tbody>
</table>
)}
</CardContent>
</Card>
</>
)}
</div>
</AppShell>
)
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<Card>
<CardContent className="py-4">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-semibold tracking-tight">{value}</div>
</CardContent>
</Card>
)
}
function formatPercent(value: number | null): string {
return value === null ? '—' : `${Math.round(value * 100)}%`
}
function formatDistance(value: number | null): string {
return value === null ? '—' : value.toFixed(3)
}
function mergeTrends(agents: AgentAnalytics[]): Record<string, string | number>[] {
const rows = agents
.flatMap((agent) =>
agent.trend.map((point) => ({
sortKey: point.decidedAtUtc,
time: new Date(point.decidedAtUtc).toLocaleDateString(),
name: agent.name,
distance: point.distance,
})),
)
.sort((a, b) => a.sortKey.localeCompare(b.sortKey))
return rows.map((row) => ({ time: row.time, [row.name]: row.distance }))
}