docs(graph): revise live-ui spec per PR #3 review (R1)

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>
This commit is contained in:
sb-iam
2026-06-27 19:28:48 -07:00
parent 5a96e2b1ae
commit 1ea3e3c9e6
+30 -15
View File
@@ -16,18 +16,30 @@ The visible self-improving loop, before → after:
**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`) gains `learnedOwner?: string` + `file?: string`, and `frontend/src/livekit/useInterventions.ts` sends `learnedOwner` (the confirming engineer) + `file` (`collision.file`). On `accepted && wasRealCollision`, `POST /api/outcome` writes `team_model.ownership[file] = learnedOwner` — the existing-but-never-written `TeamModel.ownership` type. The materializer draws `owns`/`learned_from` edges from `team_model.ownership` (**authoritative**); the most-recent-observation owner survives only as a clearly-labelled **low-confidence fallback**. Ownership is now **stored, not inferred**.
- **P1 — `changedFiles` aren't clean paths → fixed in the materializer.** They are raw `git status --short` lines (`"M src/auth.ts"`, `"?? x"`, `"R a -> b"`). `live.ts` runs `parseGitStatusPath()` (strip the XY code; post-`->` target for renames) then `normalizeFile()` so file ids match `collisions.file`.
- **P2 — ws bridge made concrete.** `GRAPH_DIRTY` (must-have) is emitted **inside the Express process** on `POST /api/outcome` (no bridge). The agent is a **separate process** publishing to LiveKit; for the nice-to-have instant `COLLISION`/`VOICE_CUE` push, the agent opens a ws **client** to `ws://127.0.0.1:${PORT}/api/events` and 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 `source` marker.** `PodGraph` gains `source: 'live' | 'demo'` (`createDemoPodGraph`=`'demo'`, materializer=`'live'`). When `source==='demo'` or live-with-zero outcomes, `GraphView` shows a **"demo · waiting for first observation"** badge and renders metric values as `—`, so `5 / 2 / 86%` never reads as live.
- **P3 — PLAN-before-code inconsistency → softened.** This PR is **spec-only**; the `docs/PLAN.md` task entry + `docs/graph.md` cross-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` (= `hasUnpushedChanges`); `file:` node per entry in `changedFiles`; `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` wins for a file's de-facto `primaryOwner` (used to label the `owns` edge — there is no `ownership_map`). Edge `label` = `activity`. |
| **`collisions`** (`Collision`) | `id`, `file`, `symbol`, `engineers[]`, `severity`, `detectedAt`, `memorySignature` | One `collision:<id>` node, `kind:'collision'`, `status:'risk'`, `weight` by severity (`info`0.4/`warn`0.7/`critical`1.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 learning signal.** For each outcome where `accepted===true && wasRealCollision===true`: emit a `learned_from` edge `intervention:<interventionId>``engineer:<primaryOwnerOfFile>`, `label` = `learned: owns <file>`, `strength` 0.6; flip that engineer node `status:'learned'` and the intervention node `status:'learned'`. `accepted===false` → intervention node `status:'stable'` (grey, "dismissed"). |
| 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 (`info`0.4/`warn`0.7/`critical`1.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`:
@@ -57,7 +69,7 @@ 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). Helper `normalizeFile()` reused from `collision/detector.ts` so file ids match collision files exactly.
`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)
@@ -81,11 +93,11 @@ Add `export async function materializeAndSeed(podId)` in `store.ts`: calls `mate
- `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 just `PodGraphMetric[]` (the outcomes aggregation: `learned owners`, `open risk paths`, `accept rate`) so the rail can poll cheaply without re-sending the whole graph. Backed by a `db.collection('outcomes').aggregate` group on `podId` (count, `$sum accepted`).
- `POST /api/outcome` (MODIFY): after `recordOutcome`, call `materializeAndSeed(podId)` (best-effort, never throws) **and** broadcast `{type:'GRAPH_DIRTY', podId}` to ws `/api/events` clients (reuse the existing `clients` set / `c.send`). This is the only place the loop closes.
- `POST /api/outcome` (MODIFY): after `recordOutcome`, when `accepted && wasRealCollision` **write `team_model.ownership[outcome.file] = outcome.learnedOwner`** (the supervised signal — `TeamModel` type, upsert on `podId`), then call `materializeAndSeed(podId)` (best-effort, never throws) **and** broadcast `{type:'GRAPH_DIRTY', podId}` to ws `/api/events` clients (reuse the existing `clients` set / `c.send`). This is the only place the loop closes.
### 3e. Realtime push — ws `/api/events` (MODIFY)
The relay already fans out any JSON. Add server-originated `GRAPH_DIRTY` (above) and pass through agent `COLLISION` / `VOICE_CUE` (the agent already publishes these on the LiveKit data channel; mirror them onto ws so the dashboard-level GraphView — which is not in a LiveKit room — can react). No new transport; just two more message `type`s on the existing bus.
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
@@ -110,7 +122,7 @@ The "Team memory" button currently passes `pods[0]?.id ?? 'demo-pod'` (line 271)
### 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 the detail rail shows a small _"Live mode — waiting for the first observation"_ note when metrics total is 0, so it's honest that nothing has been learned yet. No empty-grid placeholder.
- **Empty (no activity yet):** §3b returns the **demo graph** so the canvas is never blank — but `PodGraph` now carries **`source: 'live' | 'demo'`** (R1): `createDemoPodGraph` sets `'demo'`, the materializer `'live'`. When `graph.source==='demo'` (or `'live'` with metrics total 0), `GraphView` renders a **"demo · waiting for first observation"** badge and **suppresses the metric values (shows `—`)** so the baked `5 / 2 / 86%` never reads as live. No empty-grid placeholder.
- **Error / backend down:** `fetchPodGraph` rejects → 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
@@ -153,7 +165,7 @@ The "Team memory" button currently passes `pods[0]?.id ?? 'demo-pod'` (line 271)
## 7. Files & tasks (documentation-first)
> Per CLAUDE.md gate: this spec + the new task entries in `docs/PLAN.md` land **before** code. Add task `Pxx — Live continual-learning graph` to `PLAN.md` with the Files list below.
> Per CLAUDE.md gate: the spec lands before code. **This PR is spec-only.** The `docs/PLAN.md` task entry (`Pxx — Live continual-learning graph`, Files list below) + the `docs/graph.md` "Live data backing" cross-link land **with the first implementation PR** — `PLAN.md` is 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:**
@@ -167,16 +179,19 @@ The "Team memory" button currently passes `pods[0]?.id ?? 'demo-pod'` (line 271)
- `backend/src/server.ts``POST /api/outcome` calls `materializeAndSeed` + broadcasts `GRAPH_DIRTY`; new `GET /api/pods/:id/graph/metrics`; ws passthrough of `COLLISION`/`VOICE_CUE`/`GRAPH_DIRTY`. _(Task: routes + realtime)_
- `backend/src/agent/podman.ts` — also emit `COLLISION`/`VOICE_CUE` onto 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_URL` resolution fix; `fetchGraphMetrics`; `openGraphEvents`. _(Task: client)_
- `shared/src/messages.ts``InterventionOutcome` +`learnedOwner?`/`file?`; `TeamModel.ownership` now written (R1). _(Task: supervised signal)_
- `shared/src/graph.ts``PodGraph` +`source: 'live' | 'demo'` (R1). _(Task: live/demo honesty)_
- `frontend/src/livekit/useInterventions.ts` — Accept `postOutcome` sends `learnedOwner` + `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 not `pods[0]`. _(Task: entry point)_
- `frontend/src/components/PodCard.tsx``onOpenGraph(pod.id)` action. _(Task: entry point)_
**Unchanged contracts (do not edit):** `shared/src/graph.ts` (types already cover live), the two graph routes' signatures, `createDemoPodGraph` (kept as fallback), `seedGraph`/`graph:seed` (kept; `materializeAndSeed` reuses its write block).
**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_states` is keyed by `name`; `observations` by `engineerId`; collisions by names in `engineers[]`.** The materializer must reconcile these to one `engineer:<name>` node. Assumption (from data map): LiveKit identity == `--name` == engineer name. If they diverge, edges will orphan. **Mitigation:** key all engineer nodes off `pods.members` and match case-insensitively; drop unmatched.
- **`outcomes` has no `engineerId`/`file`** — only `interventionId`/`collisionId`. To draw the `learned_from` edge to the right engineer/file, `live.ts` must join `outcome → intervention.collisionId → collision.file/engineers` and pick the de-facto `primaryOwner` (most-recent observation on that file). If observations expired (6h TTL), fall back to the first name in `collision.engineers`.
- **`outcomes` had no owner identity (review P1) — resolved (R1):** the Accept payload now carries `learnedOwner` + `file`, persisted to `team_model.ownership[file]`, so the `learned_from`/`owns` edges are **supervised, not guessed**. The `outcome → intervention → collision` join + most-recent-observation owner remains **only** as a clearly-marked low-confidence fallback when `learnedOwner`/`team_model.ownership` is absent.
- **`observations` TTL may not fire** (init.ts indexes `observedAt` as Date but it's stored as ISO string) — so "active" windowing must compare parsed ISO timestamps in `live.ts`, not rely on TTL eviction; old observations could otherwise inflate "active." **Open:** cap to most-recent observation per `(engineerId,file)`.
- **`intervention.status` never advances past `pending`** — confirmed; the UI must color interventions from the `outcomes` join, never from `status`. Already handled in §1, but worth a one-line code comment so a future dev doesn't "fix" it by reading `status`.
- **ws `/api/events` has no pod-scoping** — `GRAPH_DIRTY` carries `podId` and the client filters; acceptable for the demo, flagged as the known shortcut.