Address all 5 review findings: - P1 supervised learning: outcome carries learnedOwner+file -> team_model.ownership (authoritative); observation owner is only a low-confidence fallback. - P1 changedFiles: parse git status --short lines (parseGitStatusPath) before matching collision files. - P2 ws bridge: GRAPH_DIRTY is in-process; agent opens a ws client to /api/events for the nice-to-have instant COLLISION/VOICE push; poll is the floor. - P2 demo metrics: PodGraph gains source:'live'|'demo'; demo/empty suppresses the baked 5/2/86% so it never reads as live. - P3: PR is spec-only; PLAN.md task lands with the implementation PR. Adds an R1 revisions section; contract changes (messages.ts, graph.ts, useInterventions.ts) listed in §7. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
29 KiB
Real Continual-Learning UI — Spec
Owner: graph data + visualization (live data). Status: demo-backed graph shipped (
docs/graph.md); this spec makes it REAL. Extendsdocs/graph.md— does not duplicate it. Same files, same contracts (shared/src/graph.ts,PodGraph, the two collections, the two routes). This spec only adds: a live materializer behindloadPodGraph, an outcomes aggregation, a ws push ofGRAPH_DIRTY, and the per-pod GraphView wiring. Everything ingraph.md(node/edge kinds,$graphLookup, demo fallback) remains the contract.
0. Purpose & framing
The demo graph already renders the "it learned" story (createDemoPodGraph → Karti owns auth, learned_from edge, 86% accept rate). The problem: none of it is real — team_model/graph_nodes/graph_edges are never written (seedGraph has zero callers), so loadPodGraph always returns the hardcoded demo. The graph looks alive but is a poster.
This spec makes the same poster a live render of the 6 collections the agent actually writes (pods, engineer_states, observations, collisions, interventions, outcomes), so the continual-learning loop the judges see is backed by data the pipeline produced this session.
The visible self-improving loop, before → after:
- Before: Two engineers edit
auth.ts, one unpushed. Acollisionsdoc is written, aninterventionsdoc (statuspending). The graph grows a redcollisiontriangle and awarnsedge to the intervention diamond. Copy: "new — first time PodMan saw this path." - After: The human clicks Accept →
POST /api/outcomewrites{accepted:true, wasRealCollision:true}. The materializer turns that outcome into alearned_fromedge (intervention → engineer, labellearned: owns auth.ts), flips the engineer node tostatus:'learned', and bumps the Learned owners metric. Copy on the next similar collision: "I've seen this before — last time the team accepted sync PR" (driven bycollisions.memorySignaturerecall, already live).
Why this is not a dashboard (hard constraint): it stays the secondary, toggle-opened view behind the pods list (graph.md), keeps the dark-Bauhaus single-canvas SVG (one graph, not a grid of charts), and every visible element is anchored to a live write + an action loop. The metrics rail is 3 numbers derived from real counts, not a wall of KPIs. The hero remains the intervention card in PodView; this view exists only to make "PodMan got better" legible in 10 seconds.
R1 — Revisions (resolves PR #3 review)
Revised after review. The five findings and resolutions (the sections below reflect these):
- P1 — learned owner was guessed, not supervised → fixed by a contract change. The Accept flow now carries the confirmed owner:
InterventionOutcome(shared/src/messages.ts) gainslearnedOwner?: string+file?: string, andfrontend/src/livekit/useInterventions.tssendslearnedOwner(the confirming engineer) +file(collision.file). Onaccepted && wasRealCollision,POST /api/outcomewritesteam_model.ownership[file] = learnedOwner— the existing-but-never-writtenTeamModel.ownershiptype. The materializer drawsowns/learned_fromedges fromteam_model.ownership(authoritative); the most-recent-observation owner survives only as a clearly-labelled low-confidence fallback. Ownership is now stored, not inferred. - P1 —
changedFilesaren't clean paths → fixed in the materializer. They are rawgit status --shortlines ("M src/auth.ts","?? x","R a -> b").live.tsrunsparseGitStatusPath()(strip the XY code; post-->target for renames) thennormalizeFile()so file ids matchcollisions.file. - P2 — ws bridge made concrete.
GRAPH_DIRTY(must-have) is emitted inside the Express process onPOST /api/outcome(no bridge). The agent is a separate process publishing to LiveKit; for the nice-to-have instantCOLLISION/VOICE_CUEpush, the agent opens a ws client tows://127.0.0.1:${PORT}/api/eventsand forwards them. If unreachable, the 5s poll covers it. Instant push = nice-to-have; poll = floor. - P2 — demo metrics shown as live → fixed with a
sourcemarker.PodGraphgainssource: 'live' | 'demo'(createDemoPodGraph='demo', materializer='live'). Whensource==='demo'or live-with-zero outcomes,GraphViewshows a "demo · waiting for first observation" badge and renders metric values as—, so5 / 2 / 86%never reads as live. - P3 — PLAN-before-code inconsistency → softened. This PR is spec-only; the
docs/PLAN.mdtask entry +docs/graph.mdcross-link land with the first implementation PR (PLAN.md is hot/concurrent; the task ships alongside its code).
Contract changes (small, additive): shared/src/messages.ts (InterventionOutcome +learnedOwner/file; TeamModel.ownership now written), shared/src/graph.ts (PodGraph.source), frontend/src/livekit/useInterventions.ts (Accept sends owner+file).
1. Live data → graph mapping
The materializer (backend/src/graph/live.ts, §3) reads these collections per podId and emits PodGraph (shared/src/graph.ts). Node ids stay stable so realtime refreshes don't reshuffle: engineer:<name>, file:<normalizedFile>, collision:<collision.id>, intervention:<intervention.id>.
| Source collection | Fields read | Produces |
|---|---|---|
pods |
members[], name, repo |
Baseline engineer: nodes for every roster member (so the graph isn't empty pre-activity). summary = pod repo. |
engineer_states (via getGitStates) |
name, changedFiles[], branch, recentCommit, gitUpdatedAt |
engineer:<name> node status:'risk' when changedFiles.length>0. changedFiles[] are raw git status --short lines (e.g. "M src/auth.ts", "?? x.ts", "R a -> b"), NOT clean paths (R1) — live.ts runs parseGitStatusPath() (strip the XY code; post--> target for renames) then normalizeFile() so file ids match collisions.file. One file: node per parsed path; editing edge engineer→file, strength 0.6. Engineer summary = "N changed files on <branch>". |
observations (EngineerContext) |
engineerId, currentFile, currentSymbol, activity, confidence, observedAt |
engineer:<engineerId> node status:'active' if a observedAt within last 60s exists; file:<currentFile> node; editing edge engineer→file, strength = confidence (Gemini meter). Most-recent observedAt gives a file's de-facto owner — used only as the low-confidence fallback for the owns edge when team_model.ownership is empty (R1). Edge label = activity. |
collisions (Collision) |
id, file, symbol, engineers[], severity, detectedAt, memorySignature |
One collision:<id> node, kind:'collision', status:'risk', weight by severity (info0.4/warn0.7/critical1.0). collides edge per name in engineers[] (engineer→collision, strength from severity). touches edge file:<file>→collision. A summary badge "seen before" when memorySignature matched a prior collision (severity escalated to critical — already the live recall signal). |
interventions (Intervention) |
id, collisionId, kind, suggestedAction.kind, status, createdAt |
One intervention:<id> diamond. warns edge collision:<collisionId>→intervention, label from suggestedAction.kind (open_sync_pr→"sync PR", ping_teammate→"ping", none→"watch"). Color cannot come from status (always pending — never updated), so it is joined to outcomes (next row). |
outcomes (InterventionOutcome) |
interventionId, wasRealCollision, accepted, recordedAt |
The supervised learning signal (R1). On accepted && wasRealCollision the outcome carries learnedOwner + file; the server writes team_model.ownership[file]=learnedOwner. The materializer reads team_model.ownership (authoritative) to draw the owns edge and a learned_from edge intervention:<id>→engineer:<learnedOwner> (label: learned: owns <file>, strength 0.6), flipping that engineer + intervention to status:'learned'. accepted===false → intervention status:'stable' ("dismissed"). Observation-based ownership is only a low-confidence fallback when team_model.ownership is empty — never the primary signal. |
Metrics rail (PodGraphMetric[], replacing demo's hardcoded 5 / 2 / 86%), computed live in live.ts:
label |
value |
detail |
Source |
|---|---|---|---|
Learned owners |
count of accepted+real outcomes | "Ownership edges retained from accepted interventions." |
outcomes aggregation |
Open risk paths |
count of collisions with engineers.length>=2 and any colliding engineer has unpushed (engineer_states.changedFiles non-empty) |
"Files with converging editors and unpushed work." |
collisions × engineer_states |
Accept rate |
round(accepted / total outcomes * 100)% ("—" when total 0) |
"Interventions accepted this session." |
outcomes aggregation |
x/y layout: live.ts runs a deterministic column layout (engineers x≈78, files x≈300, collisions x≈470, interventions/features x≈620; y spread evenly per column within the 0..472 viewBox) so the existing SVG renders unchanged.
2. The visible workflow (observe → store → predict → outcome → adapt)
The graph view narrates the same loop the agent runs, mapped to what the viewer sees:
- Observe (
observations, ~1fps;engineer_states, 15s): engineer nodes lightactive;editingedges thicken with Geminiconfidence. A small caption under the canvas: "Yahya — editing auth.ts (unpushed)" from the latest observation + git chip. - Store / Predict (
collisions+ vector recall): when ≥2 engineers + unpushed, a redcollisiontriangle andcollides/touchesedges appear. IfmemorySignaturerecall hit (severitycritical), the node carries a "seen before" badge — the self-improvement signal. - Intervene (
interventions): awarnsedge draws to a newinterventiondiamond labeled bysuggestedAction.kind. The view shows a "PodMan is speaking" pulse on that edge when aVOICE_CUEarrives over ws (§5). - Outcome (
POST /api/outcomefrom thePodViewcard): the moment the human clicks Accept. - Adapt (
outcomes→learned_from): on the next graph refresh, a dashed purplelearned_fromedge animates in, the engineer node flips tolearned(gold/locked), and Learned owners + Accept rate tick up.
The live "money moment" (sequence the demo lands): collision detected → red triangle + edges appear and PodMan speaks (pulse) → human clicks Accept on the card in PodView → switch to Team memory → the learned_from edge to engineer:karti (learned: owns auth.ts) is now present, the engineer is gold, metrics rose. A second collision on the same signature renders with the "seen before" badge. That single before→after transition, all from collections written this session, is the continual-learning proof.
3. Backend changes
All additive / behind existing signatures — graph.md contracts unchanged.
3a. Live materializer — backend/src/graph/live.ts (NEW)
export async function materializePodGraph(podId: string): Promise<PodGraph>. Reads the 6 collections via collections() + getGitStates(podId) (memory/db.ts), builds nodes/edges/metrics per §1, runs the column layout, returns PodGraph. Pure-read; never writes. Wrapped so any Mongo error throws to the caller (which falls back to demo, §3b). Helpers: parseGitStatusPath() (strip the git status --short XY code; take the post--> target for renames) applied to engineer_states.changedFiles, then normalizeFile() reused from collision/detector.ts so file ids match collisions.file exactly. Ownership for owns/learned_from edges is read from team_model.ownership (authoritative); observation-based ownership is only a marked low-confidence fallback.
3b. loadPodGraph — backend/src/graph/store.ts (MODIFY)
Replace the body so it prefers live, then team_model, then demo:
1. const graph = await materializePodGraph(podId)
2. if (graph.nodes.length > <BASELINE>) return graph // real activity exists
3. const doc = team_model.findOne({podId}); if (doc?.graph) return doc.graph
4. return createDemoPodGraph(podId) // demo-stability fallback
<BASELINE> = the count of pure roster engineer: nodes (no files/collisions). If only roster nodes exist (no observations/collisions yet) the view shows demo so the canvas is never empty mid-demo (§5). Signature, route, and demo fallback all unchanged.
3c. Keep $graphLookup real — mirror into graph_nodes/graph_edges
Add export async function materializeAndSeed(podId) in store.ts: calls materializePodGraph, then reuses seedGraph's existing upsert/deleteMany/insertMany block to write team_model.graph + graph_nodes + graph_edges from the live graph (today seedGraph writes the demo graph from createDemoPodGraph; this variant takes the live one). Called (a) on every POST /api/outcome (so reachFrom reflects the new learned_from edge), and (b) lazily inside the graph route after loadPodGraph returns a live graph. This is the only way reachFrom//graph/reach/:node stops returning empty.
3d. Routes — backend/src/server.ts (MODIFY, additive only)
GET /api/pods/:id/graph— unchanged signature; now returns live graph via §3b.GET /api/pods/:id/graph/reach/:node— unchanged; now non-empty once §3c runs.GET /api/pods/:id/graph/metrics(NEW, small) — returns justPodGraphMetric[](the outcomes aggregation:learned owners,open risk paths,accept rate) so the rail can poll cheaply without re-sending the whole graph. Backed by adb.collection('outcomes').aggregategroup onpodId(count,$sum accepted).POST /api/outcome(MODIFY): afterrecordOutcome, whenaccepted && wasRealCollisionwriteteam_model.ownership[outcome.file] = outcome.learnedOwner(the supervised signal —TeamModeltype, upsert onpodId), then callmaterializeAndSeed(podId)(best-effort, never throws) and broadcast{type:'GRAPH_DIRTY', podId}to ws/api/eventsclients (reuse the existingclientsset /c.send). This is the only place the loop closes.
3e. Realtime push — ws /api/events (MODIFY)
The relay already fans out any JSON. GRAPH_DIRTY needs no bridge — it is emitted from inside the Express process on POST /api/outcome, so it reaches ws clients directly. The agent runs in a SEPARATE process (dev:agent / agent.ts) and publishes COLLISION/VOICE_CUE on the LiveKit data channel, which the server's ws clients never see. Concrete bridge for the (nice-to-have) instant push: the agent opens a WebSocket client to ws://127.0.0.1:${PORT}/api/events and forwards the same COLLISION/VOICE_CUE JSON; the relay fans it out. If the agent can't reach the API, the 5s poll (§5) covers it (≤5s). This instant push is explicitly nice-to-have (§6); the poll is the floor.
4. Frontend changes
4a. Per-pod entry point — frontend/src/App.tsx (MODIFY)
The "Team memory" button currently passes pods[0]?.id ?? 'demo-pod' (line 271) — wrong pod for a multi-pod demo. Change to open the graph for the pod in context: add a BrainCircuitIcon action on each PodCard (onOpenGraph(pod.id)) wired to setGraphPodId(pod.id), and keep the header button as a fallback that opens the selected/first live pod (the one with presence). The router slot (if (graphPodId) return <GraphView podId={graphPodId} …/>, line 237-239) is unchanged.
4b. GraphView.tsx — keep dark-Bauhaus, add realtime
Stays dark-Bauhaus (per graph.md it is the deliberately distinct "it learned" surface — do not convert the SVG/.pm-* palette to shadcn). What changes:
- Realtime refresh: open a ws to
/api/eventson mount; on{type:'GRAPH_DIRTY', podId}(matching this pod) orCOLLISION/VOICE_CUE, re-callfetchPodGraph(podId)(andfetchGraphMetrics). Also a 5s poll offetchPodGraphas the floor (mirrorsApp.tsx's existing 5s presence/memory poll) so it's live even if ws drops. New incoming nodes/edges fade in (CSS opacity transition on<line>/shape);learned_fromedges animate the dashed stroke. - "new" vs "seen before" copy in the detail rail from
collisionnodesummarybadge (§1). - Speaking pulse on the
warnsedge when aVOICE_CUElands.
4c. frontend/src/lib/graph.ts (MODIFY)
- Fix
BACKEND_URLto followlib/api.ts's resolution (empty string in prod) instead of hardcodinglocalhost:8787. - Add
fetchGraphMetrics(podId)→GET /api/pods/:id/graph/metrics. - Add
openGraphEvents(podId, onDirty)→ thinWebSocket('/api/events')subscription helper (reused byGraphView).
4d. Loading / empty / error states (collections empty is the common real case)
- Loading: existing on-mount spinner; keep.
- Empty (no activity yet): §3b returns the demo graph so the canvas is never blank — but
PodGraphnow carriessource: 'live' | 'demo'(R1):createDemoPodGraphsets'demo', the materializer'live'. Whengraph.source==='demo'(or'live'with metrics total 0),GraphViewrenders a "demo · waiting for first observation" badge and suppresses the metric values (shows—) so the baked5 / 2 / 86%never reads as live. No empty-grid placeholder. - Error / backend down:
fetchPodGraphrejects → render the last good graph if any, else the demo graph rendered client-side is not available; show a single-line.pm-error chip "memory offline — retrying" and keep polling. ws errors are swallowed (poll covers it).
5. Realtime & data freshness
| Surface | Transport | Cadence |
|---|---|---|
Engineer active/file edits |
ws COLLISION passthrough + 5s fetchPodGraph poll |
~1fps source data, surfaced ≤5s |
| Git chip (changed files / branch) | folded into 5s graph poll (engineer_states) |
15s underlying write |
| Collision / intervention nodes | ws COLLISION (instant) → triggers refetch |
instant on event |
| Speaking pulse | ws VOICE_CUE |
instant |
learned_from edge + metrics |
ws GRAPH_DIRTY on POST /api/outcome → refetch |
instant on Accept |
Polling is the floor, ws is the accelerator — never gate the canvas on ws. Demo-stability fallback: if the live materializer yields ≤ baseline nodes or Mongo is unreachable, the route serves createDemoPodGraph (§3b) so the toggle always shows a coherent graph on stage. The Accept→learned_from beat is driven by POST /api/outcome → materializeAndSeed → GRAPH_DIRTY, the one path that must be solid.
6. Demo path
Must-have (the 3-min money moment):
materializePodGraphreadingcollisions+interventions+outcomes+engineer_statesso the graph reflects this session.POST /api/outcome→materializeAndSeed+GRAPH_DIRTY; GraphView refetches and thelearned_fromedge + gold node + risen metrics appear after Accept.- Live metrics rail (Learned owners / Open risk paths / Accept rate) from the outcomes aggregation.
- "seen before" badge from
collisions.memorySignature(already live) on the second collision. - Demo-graph fallback when collections are empty (stage safety).
Nice-to-have:
- ws
VOICE_CUEspeaking pulse on thewarnsedge. /graph/reach/:nodelit risk-path walk ($graphLookup) oncematerializeAndSeedpopulatesgraph_edges.- Fade/stroke animations on incoming edges.
- Per-
PodCardgraph entry button.
Cut if behind (per CLAUDE.md 12h box):
- Vector-search dependency for recall (exact
memorySignaturerecall already covers the learning beat). - Any new chart primitive / recharts — keep the SVG.
- Auth/pod-scoping on ws
/api/events. - Historical/time-scrubbed graph; only "now" is needed.
The 3-min beat (extends PLAN.md §9, ends in this view): IDE with unpushed auth.ts → live caption → second engineer opens same file → COLLISION card in PodView (named teammate + sync PR action) → Hermes voice → human clicks Accept → toggle Team memory → the learned_from "learned: Karti owns auth.ts" edge is now real, metrics rose → second collision shows "I've seen this before" → close on the public repo PR URL + the live metrics.
7. Files & tasks (documentation-first)
Per CLAUDE.md gate: the spec lands before code. This PR is spec-only. The
docs/PLAN.mdtask entry (Pxx — Live continual-learning graph, Files list below) + thedocs/graph.md"Live data backing" cross-link land with the first implementation PR —PLAN.mdis a hot, concurrently-edited file, so the task ships alongside the code that satisfies it rather than as a separate doc-only edit that would conflict.
Create:
backend/src/graph/live.ts—materializePodGraph(podId),normalizeFile, metrics aggregation, column layout. (Task: live materializer)
Modify:
docs/graph.md— append a "Live data backing" section pointing to this spec (the §1 mapping table +live.ts); change the "Demo-first plan" step 3 ("SwaploadPodGraph…") to "done viamaterializePodGraph." (documentation-first)docs/PLAN.md— add the task + Files list. (documentation-first)backend/src/graph/store.ts—loadPodGraphprefers live (§3b); addmaterializeAndSeed(podId)(§3c). (Task: live wiring + $graphLookup)backend/src/server.ts—POST /api/outcomecallsmaterializeAndSeed+ broadcastsGRAPH_DIRTY; newGET /api/pods/:id/graph/metrics; ws passthrough ofCOLLISION/VOICE_CUE/GRAPH_DIRTY. (Task: routes + realtime)backend/src/agent/podman.ts— also emitCOLLISION/VOICE_CUEonto ws/api/events(mirror of the LiveKit data-channel publish) so the dashboard-level GraphView reacts. (Task: realtime mirror)frontend/src/lib/graph.ts—BACKEND_URLresolution fix;fetchGraphMetrics;openGraphEvents. (Task: client)shared/src/messages.ts—InterventionOutcome+learnedOwner?/file?;TeamModel.ownershipnow written (R1). (Task: supervised signal)shared/src/graph.ts—PodGraph+source: 'live' | 'demo'(R1). (Task: live/demo honesty)frontend/src/livekit/useInterventions.ts— AcceptpostOutcomesendslearnedOwner+file(R1). (Task: supervised signal)frontend/src/components/GraphView.tsx— ws subscription + 5s poll + animations + "new/seen-before" rail copy + speaking pulse; keep dark-Bauhaus. (Task: client)frontend/src/App.tsx— per-pod graph entry; header button opens live/selected pod notpods[0]. (Task: entry point)frontend/src/components/PodCard.tsx—onOpenGraph(pod.id)action. (Task: entry point)
Contract changes (small, additive — R1): shared/src/messages.ts (InterventionOutcome +learnedOwner/file; TeamModel.ownership now written), shared/src/graph.ts (PodGraph +source), frontend/src/livekit/useInterventions.ts (Accept sends owner+file). Still unchanged: the two graph routes' signatures, node/edge kinds, createDemoPodGraph (kept as fallback), seedGraph/graph:seed (kept; materializeAndSeed reuses its write block).
8. Risks & open questions
engineer_statesis keyed byname;observationsbyengineerId; collisions by names inengineers[]. The materializer must reconcile these to oneengineer:<name>node. Assumption (from data map): LiveKit identity ==--name== engineer name. If they diverge, edges will orphan. Mitigation: key all engineer nodes offpods.membersand match case-insensitively; drop unmatched.outcomeshad no owner identity (review P1) — resolved (R1): the Accept payload now carrieslearnedOwner+file, persisted toteam_model.ownership[file], so thelearned_from/ownsedges are supervised, not guessed. Theoutcome → intervention → collisionjoin + most-recent-observation owner remains only as a clearly-marked low-confidence fallback whenlearnedOwner/team_model.ownershipis absent.observationsTTL may not fire (init.ts indexesobservedAtas Date but it's stored as ISO string) — so "active" windowing must compare parsed ISO timestamps inlive.ts, not rely on TTL eviction; old observations could otherwise inflate "active." Open: cap to most-recent observation per(engineerId,file).intervention.statusnever advances pastpending— confirmed; the UI must color interventions from theoutcomesjoin, never fromstatus. Already handled in §1, but worth a one-line code comment so a future dev doesn't "fix" it by readingstatus.- ws
/api/eventshas no pod-scoping —GRAPH_DIRTYcarriespodIdand the client filters; acceptable for the demo, flagged as the known shortcut. - Open question: should
materializeAndSeedrun on everyPOST /api/outcome(simple, slightly heavy) or be debounced? For a 3-engineer demo, run inline; revisit only if outcome volume spikes. - Open question: when both a live
materializePodGraphgraph and ateam_model.graphexist, live wins (§3b). Confirm no flow expectsteam_model.graphto be authoritative — currently nothing writes it except the deadseedGraph, so live-wins is safe.