Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f11d7792a | |||
| 9870a1c710 | |||
| 5e9929f8da | |||
| c97863e05e | |||
| 03a8af6da0 | |||
| 62f5d26f83 | |||
| ad73659389 |
@@ -8,24 +8,62 @@ import type { PodGraph } from '@podman/shared';
|
||||
* auth* — is the continual-learning story the demo lights up.
|
||||
*/
|
||||
export function createDemoPodGraph(podId: string): PodGraph {
|
||||
const base = Date.now();
|
||||
const at = (secAgo: number): string => new Date(base - secAgo * 1000).toISOString();
|
||||
return {
|
||||
podId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
loop: [
|
||||
{ key: 'observe', title: 'OBSERVE', value: '5', detail: '~5/s vision contexts', active: false },
|
||||
{ key: 'store', title: 'STORE', value: '124', detail: 'memory vectors · Atlas', active: false },
|
||||
{ key: 'predict', title: 'PREDICT', value: '1', detail: 'open risk path', active: true },
|
||||
{ key: 'outcome', title: 'OUTCOME', value: '1/0', detail: 'accepted · dismissed', active: false },
|
||||
{ key: 'adapt', title: 'ADAPT', value: '3', detail: 'learned owners', active: false },
|
||||
],
|
||||
activity: [
|
||||
{
|
||||
id: 'demo-learn',
|
||||
at: at(20),
|
||||
kind: 'learned_from',
|
||||
text: 'Memory updated: Karti owns auth.ts (confidence ↑)',
|
||||
},
|
||||
{ id: 'demo-out', at: at(24), kind: 'outcome', text: 'Intervention accepted by the pod' },
|
||||
{
|
||||
id: 'demo-warn',
|
||||
at: at(40),
|
||||
kind: 'warns',
|
||||
text: 'PodMan: "Karti & Yahya are both in auth.ts — open a sync PR?" → card sent',
|
||||
},
|
||||
{
|
||||
id: 'demo-col',
|
||||
at: at(58),
|
||||
kind: 'collision',
|
||||
text: 'Critical overlap on auth.ts · Karti + Yahya',
|
||||
},
|
||||
{
|
||||
id: 'demo-edit',
|
||||
at: at(72),
|
||||
kind: 'editing',
|
||||
text: 'Yahya opened auth.ts — unpushed changes',
|
||||
},
|
||||
],
|
||||
// Kept consistent with the graph below (3 owner engineers, 1 collision file,
|
||||
// 1 of 1 interventions accepted) so the numbers never contradict the picture.
|
||||
metrics: [
|
||||
{
|
||||
label: 'Learned owners',
|
||||
value: '5',
|
||||
detail: 'Ownership edges retained from accepted interventions.',
|
||||
value: '3',
|
||||
detail: 'Distinct owners retained from accepted interventions.',
|
||||
},
|
||||
{
|
||||
label: 'Open risk paths',
|
||||
value: '2',
|
||||
detail: 'auth.ts and the memory API have converging editors.',
|
||||
value: '1',
|
||||
detail: 'File with two or more converging editors.',
|
||||
},
|
||||
{
|
||||
label: 'Accept rate',
|
||||
value: '86%',
|
||||
detail: 'Interventions accepted this session (+14%).',
|
||||
value: '100%',
|
||||
detail: 'Interventions accepted vs total this session.',
|
||||
},
|
||||
],
|
||||
nodes: [
|
||||
|
||||
+249
-12
@@ -6,6 +6,13 @@ import type {
|
||||
PodGraphNodeKind,
|
||||
PodGraphEdgeKind,
|
||||
PodGraphNodeStatus,
|
||||
LearningStage,
|
||||
LearningStageKey,
|
||||
ActivityEvent,
|
||||
EngineerContext,
|
||||
Collision,
|
||||
Intervention,
|
||||
InterventionOutcome,
|
||||
} from '@podman/shared';
|
||||
import { collections, getGitStates, getDb } from '../memory/db.js';
|
||||
|
||||
@@ -148,6 +155,185 @@ function layout(nodes: PodGraphNode[]): void {
|
||||
|
||||
const SEVERITY_WEIGHT: Record<string, number> = { info: 0.4, warn: 0.7, critical: 1 };
|
||||
|
||||
/** Parse any timestamp-ish value to epoch ms (0 when missing/unparseable). */
|
||||
function ms(t: string | Date | null | undefined): number {
|
||||
if (!t) return 0;
|
||||
const v = new Date(t).getTime();
|
||||
return Number.isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
const OBSERVE_WINDOW_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Live counts for the learning-loop rail (observe→store→predict→outcome→adapt).
|
||||
* The "active" stage is the one whose latest underlying event is most recent —
|
||||
* with deeper stages winning ties so the rail lights up at the furthest point
|
||||
* the pod reached this session. Additive: derived from already-fetched docs.
|
||||
*/
|
||||
function buildLoop(opts: {
|
||||
now: number;
|
||||
observations: EngineerContext[];
|
||||
collisions: Collision[];
|
||||
outcomes: InterventionOutcome[];
|
||||
riskPaths: number;
|
||||
vectorCount: number;
|
||||
learnedOwners: number;
|
||||
}): LearningStage[] {
|
||||
const { now, observations, collisions, outcomes, riskPaths, vectorCount, learnedOwners } = opts;
|
||||
|
||||
const recentObs = observations.filter((o) => now - ms(o.observedAt) < OBSERVE_WINDOW_MS).length;
|
||||
const rate = (recentObs / 60).toFixed(1);
|
||||
const accepted = outcomes.filter((o) => o.accepted).length;
|
||||
const dismissed = outcomes.filter((o) => !o.accepted).length;
|
||||
|
||||
// Latest event time per stage; `store` sits just behind `predict` so a shared
|
||||
// collision timestamp resolves to PREDICT rather than STORE.
|
||||
const latestObs = Math.max(0, ...observations.map((o) => ms(o.observedAt)));
|
||||
const latestCol = Math.max(0, ...collisions.map((c) => ms(c.detectedAt)));
|
||||
const latestOut = Math.max(0, ...outcomes.map((o) => ms(o.recordedAt)));
|
||||
const latestAdapt = Math.max(
|
||||
0,
|
||||
...outcomes.filter((o) => o.accepted && o.wasRealCollision).map((o) => ms(o.recordedAt)),
|
||||
);
|
||||
|
||||
const refs: Array<[LearningStageKey, number]> = [
|
||||
['observe', latestObs],
|
||||
['store', latestCol ? latestCol - 1 : 0],
|
||||
['predict', latestCol],
|
||||
['outcome', latestOut],
|
||||
['adapt', latestAdapt],
|
||||
];
|
||||
let activeKey: LearningStageKey = 'observe';
|
||||
let best = 0;
|
||||
for (const [k, t] of refs) {
|
||||
if (t > 0 && t >= best) {
|
||||
best = t;
|
||||
activeKey = k;
|
||||
}
|
||||
}
|
||||
|
||||
const stages: Array<Omit<LearningStage, 'active'>> = [
|
||||
{ key: 'observe', title: 'OBSERVE', value: String(recentObs), detail: `~${rate}/s vision contexts` },
|
||||
{ key: 'store', title: 'STORE', value: String(vectorCount), detail: 'memory vectors · Atlas' },
|
||||
{
|
||||
key: 'predict',
|
||||
title: 'PREDICT',
|
||||
value: String(riskPaths),
|
||||
detail: `open risk path${riskPaths === 1 ? '' : 's'}`,
|
||||
},
|
||||
{ key: 'outcome', title: 'OUTCOME', value: `${accepted}/${dismissed}`, detail: 'accepted · dismissed' },
|
||||
{
|
||||
key: 'adapt',
|
||||
title: 'ADAPT',
|
||||
value: String(learnedOwners),
|
||||
detail: `learned owner${learnedOwners === 1 ? '' : 's'}`,
|
||||
},
|
||||
];
|
||||
return stages.map((s) => ({ ...s, active: s.key === activeKey }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge + time-sort recent events into the activity stream feed. Reuses the same
|
||||
* de-noise (isFilePath / ENGINEER_NOISE / signature collapse) as the graph so
|
||||
* the feed never shows junk paths or test-artifact engineers. Capped to 8.
|
||||
*/
|
||||
function buildActivity(opts: {
|
||||
observations: EngineerContext[];
|
||||
collisions: Collision[];
|
||||
interventions: Intervention[];
|
||||
outcomes: InterventionOutcome[];
|
||||
ownership: Record<string, string>;
|
||||
}): ActivityEvent[] {
|
||||
const { observations, collisions, interventions, outcomes, ownership } = opts;
|
||||
const cleanEng = (n: string): boolean => Boolean(n) && !ENGINEER_NOISE.test(n);
|
||||
const out: ActivityEvent[] = [];
|
||||
|
||||
// editing — newest observation per (engineer, file); observations arrive desc.
|
||||
const seenEdit = new Set<string>();
|
||||
for (const o of observations) {
|
||||
if (!o.engineerId || !cleanEng(o.engineerId)) continue;
|
||||
const file = o.currentFile ? normalizeFile(o.currentFile) : '';
|
||||
if (!isFilePath(file)) continue;
|
||||
const key = `${o.engineerId.toLowerCase()}|${file}`;
|
||||
if (seenEdit.has(key)) continue;
|
||||
seenEdit.add(key);
|
||||
out.push({
|
||||
id: `edit:${o.engineerId}:${file}`,
|
||||
at: o.observedAt,
|
||||
kind: 'editing',
|
||||
text: `${o.engineerId} opened ${shortLabel(file)}${
|
||||
o.hasUnpushedChanges ? ' — unpushed changes' : ''
|
||||
}`,
|
||||
});
|
||||
}
|
||||
|
||||
// collision — collapse by signature, newest first.
|
||||
const seenCol = new Set<string>();
|
||||
for (const c of collisions) {
|
||||
const file = normalizeFile(c.file);
|
||||
if (!isFilePath(file)) continue;
|
||||
const sig = (c as { memorySignature?: string }).memorySignature ?? `${file}#${c.symbol ?? ''}`;
|
||||
if (seenCol.has(sig)) continue;
|
||||
seenCol.add(sig);
|
||||
const engs = c.engineers.filter(cleanEng);
|
||||
if (!engs.length) continue;
|
||||
out.push({
|
||||
id: `col:${c.id}`,
|
||||
at: c.detectedAt,
|
||||
kind: 'collision',
|
||||
text: `${c.severity === 'critical' ? 'Critical overlap' : 'Overlap'} on ${shortLabel(
|
||||
file,
|
||||
)} · ${engs.join(' + ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
// warns — interventions PodMan raised.
|
||||
for (const iv of interventions) {
|
||||
if (!iv.message) continue;
|
||||
const msg = iv.message.length > 64 ? `${iv.message.slice(0, 61)}…` : iv.message;
|
||||
out.push({
|
||||
id: `warn:${iv.id}`,
|
||||
at: iv.createdAt,
|
||||
kind: 'warns',
|
||||
text: `PodMan: "${msg}" → card sent`,
|
||||
});
|
||||
}
|
||||
|
||||
// outcome + learned_from — the supervised learning beat.
|
||||
const colById = new Map(collisions.map((c) => [c.id, c]));
|
||||
const ivById = new Map(interventions.map((i) => [i.id, i]));
|
||||
for (const o of outcomes) {
|
||||
if (!o.accepted) continue;
|
||||
out.push({
|
||||
id: `out:${o.interventionId}`,
|
||||
at: o.recordedAt,
|
||||
kind: 'outcome',
|
||||
text: 'Intervention accepted by the pod',
|
||||
});
|
||||
if (!o.wasRealCollision) continue;
|
||||
const iv = ivById.get(o.interventionId);
|
||||
const col = iv ? colById.get(iv.collisionId) : colById.get(o.collisionId);
|
||||
if (!col) continue;
|
||||
const file = normalizeFile(col.file);
|
||||
if (!isFilePath(file)) continue;
|
||||
const owner =
|
||||
(o as { learnedOwner?: string }).learnedOwner ??
|
||||
ownership[file] ??
|
||||
col.engineers.find(cleanEng) ??
|
||||
col.engineers[0];
|
||||
if (!owner) continue;
|
||||
out.push({
|
||||
id: `learn:${o.interventionId}`,
|
||||
at: o.recordedAt,
|
||||
kind: 'learned_from',
|
||||
text: `Memory updated: ${owner} owns ${shortLabel(file)} (confidence ↑)`,
|
||||
});
|
||||
}
|
||||
|
||||
out.sort((a, b) => ms(b.at) - ms(a.at));
|
||||
return out.slice(0, 8);
|
||||
}
|
||||
|
||||
export async function materializePodGraph(podId: string): Promise<PodGraph | null> {
|
||||
const c = await collections();
|
||||
const db = await getDb();
|
||||
@@ -363,38 +549,89 @@ export async function materializePodGraph(podId: string): Promise<PodGraph | nul
|
||||
|
||||
layout(nodes);
|
||||
|
||||
// Metrics are derived from the FINAL de-noised graph (not raw docs) so the
|
||||
// numbers match what's actually on screen. Counting raw collision signatures /
|
||||
// accepted-outcome rows inflates them with test churn (e.g. 50 "risk paths" for
|
||||
// 2 files), which reads as fake — these count distinct visible entities instead.
|
||||
const finalEdges = [...b.edges.values()];
|
||||
|
||||
// Open risk paths = distinct files carrying a surviving collision (the triangles).
|
||||
const riskFiles = new Set<string>();
|
||||
for (const e of finalEdges) {
|
||||
if (e.kind === 'touches' && b.nodes.get(e.source)?.kind === 'file') riskFiles.add(e.source);
|
||||
}
|
||||
const collisionNodeCount = nodes.filter((n) => n.kind === 'collision').length;
|
||||
const riskPaths = riskFiles.size || collisionNodeCount;
|
||||
|
||||
// Learned owners = distinct engineers PodMan retained as owners from accepted
|
||||
// interventions (the owns / learned_from edges actually drawn).
|
||||
const ownerSet = new Set<string>();
|
||||
for (const e of finalEdges) {
|
||||
if (e.kind === 'learned_from') ownerSet.add(e.target);
|
||||
if (e.kind === 'owns') ownerSet.add(e.source);
|
||||
}
|
||||
const learnedOwners = [...ownerSet].filter((id) => b.nodes.get(id)?.kind === 'engineer').length;
|
||||
|
||||
const acceptedReal = outcomeDocs.filter((o) => o.accepted && o.wasRealCollision).length;
|
||||
const totalOutcomes = outcomeDocs.length;
|
||||
const riskPaths = new Set(
|
||||
collisionDocs.map(
|
||||
(col) =>
|
||||
(col as { memorySignature?: string }).memorySignature ??
|
||||
`${normalizeFile(col.file)}#${col.symbol ?? ''}`,
|
||||
),
|
||||
).size;
|
||||
const acceptRate = totalOutcomes ? Math.round((acceptedReal / totalOutcomes) * 100) : null;
|
||||
|
||||
const metrics: PodGraphMetric[] = [
|
||||
{
|
||||
label: 'Learned owners',
|
||||
value: String(acceptedReal),
|
||||
detail: 'Ownership retained from accepted interventions.',
|
||||
value: String(learnedOwners),
|
||||
detail: 'Distinct owners retained from accepted interventions.',
|
||||
},
|
||||
{
|
||||
label: 'Open risk paths',
|
||||
value: String(riskPaths),
|
||||
detail: 'Files with two or more converging editors.',
|
||||
detail: `${riskPaths === 1 ? 'File' : 'Files'} with two or more converging editors.`,
|
||||
},
|
||||
{
|
||||
label: 'Accept rate',
|
||||
value: totalOutcomes ? `${Math.round((acceptedReal / totalOutcomes) * 100)}%` : '—',
|
||||
detail: 'Interventions accepted this session.',
|
||||
value: acceptRate == null ? '—' : `${acceptRate}%`,
|
||||
detail: 'Interventions accepted vs total this session.',
|
||||
},
|
||||
];
|
||||
|
||||
// Stored vectors for the STORE stage: prefer a real memory_vectors count,
|
||||
// fall back to collisions carrying an embedding, then to collision count.
|
||||
let vectorCount = 0;
|
||||
try {
|
||||
vectorCount = await db.collection('memory_vectors').countDocuments({ podId });
|
||||
} catch {
|
||||
/* memory_vectors is optional */
|
||||
}
|
||||
if (!vectorCount)
|
||||
vectorCount = collisionDocs.filter(
|
||||
(c) => (c as { embedding?: number[] }).embedding?.length,
|
||||
).length;
|
||||
if (!vectorCount) vectorCount = collisionDocs.length;
|
||||
|
||||
const loop = buildLoop({
|
||||
now,
|
||||
observations,
|
||||
collisions: collisionDocs,
|
||||
outcomes: outcomeDocs,
|
||||
riskPaths,
|
||||
vectorCount,
|
||||
learnedOwners,
|
||||
});
|
||||
const activity = buildActivity({
|
||||
observations,
|
||||
collisions: collisionDocs,
|
||||
interventions: interventionDocs,
|
||||
outcomes: outcomeDocs,
|
||||
ownership,
|
||||
});
|
||||
|
||||
return {
|
||||
podId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
nodes,
|
||||
edges: [...b.edges.values()],
|
||||
metrics,
|
||||
loop,
|
||||
activity,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
# Team Memory Graph — Redesign Brief (fresh-session handoff)
|
||||
|
||||
> **You are a fresh Claude Code session with no prior context. Read this whole file first.**
|
||||
> Your job: rebuild the **live "Team memory" graph view** so the **light/real-data** version is as
|
||||
> polished and functional as the original **dark Bauhaus mock**, and make the graph **dynamic**
|
||||
> (force-directed + animated), not the current dead static-column layout.
|
||||
> **Do not rewrite the backend materializer — it is good.** The problem is 100% the frontend rendering.
|
||||
|
||||
---
|
||||
|
||||
## 0. Mission (one paragraph)
|
||||
|
||||
PodMan's "Team memory" is a per-pod graph of who owns/edits which files, where work collides, and what
|
||||
PodMan learned from accepted interventions — the continual-learning loop made legible in 10 seconds.
|
||||
A **dark Bauhaus mock** of this view looks great (clean 3-panel layout, a learning-loop rail, an activity
|
||||
stream, a readable graph). The **shipped light version on real data looks terrible** (a hairball of red
|
||||
edges, overlapping labels, a static lifeless layout, and it's missing the learning-loop rail + activity
|
||||
stream entirely). Make the light version match the mock's structure/polish/functionality, in the app's
|
||||
**light shadcn theme**, and make the **graph dynamic** (organic force-directed layout, draggable,
|
||||
animated transitions). Keep using **real data** from the existing materializer.
|
||||
|
||||
---
|
||||
|
||||
## 1. The two reference points
|
||||
|
||||
### A. The dark Bauhaus mock = what "good" looks like (target structure)
|
||||
|
||||
A single dark card titled **"PODMAN — CONTINUAL-LEARNING OBSERVATORY"** with a `LIVE · POD demo-pod`
|
||||
status. Layout:
|
||||
|
||||
- **Left rail — WORKFLOW METRICS**: a vertical stack of bordered cards, each a big numeral + an
|
||||
UPPERCASE tracked label + a one-line detail, with a colored left-accent bar:
|
||||
`03 PODS WATCHED`, `05 ENGINEERS LIVE`, `02 COLLISIONS OPEN (▲ auth.ts critical)`,
|
||||
`01 INTERVENTION SENT`, `86% ACCEPT RATE (▲ +14% this session)`, `124 MEMORY VECTORS`.
|
||||
- **Center — the GRAPH**: sparse, geometric, readable. Node shapes encode kind
|
||||
(engineer = filled square, file = outlined square, feature = circle, collision = triangle,
|
||||
intervention = diamond). One **risk path is lit** (Karti+Yahya → auth.ts → collision → sync PR →
|
||||
`learned_from`), everything else dimmed. Edges color-coded (`collides` red, `warns` amber/orange,
|
||||
`learned_from` dashed violet, `owns` blue, `editing` paper, `touches` grey).
|
||||
- **Right rail — LEARNING LOOP**: a vertical 5-step stepper with the active step highlighted/pulsing:
|
||||
`01 OBSERVE (vision → 5 contexts/s)` → `02 STORE (124 vectors · Atlas)` →
|
||||
`03 PREDICT (2 collisions flagged)` [active] → `04 OUTCOME (1 accepted · 0 dismissed)` →
|
||||
`05 ADAPT (Karti→auth ownership +)`. Arrows between steps.
|
||||
- **Bottom-left — ACTIVITY STREAM**: a time-stamped feed with colored kind-tags:
|
||||
`15:48 EDITING Yahya opened auth.ts — unpushed changes detected`,
|
||||
`15:48 COLLISION Critical overlap on auth.ts · Karti + Yahya`,
|
||||
`15:49 WARNS PodMan spoke: "open a sync PR?" → card sent`,
|
||||
`15:49 OUTCOME Sync PR accepted by the pod`,
|
||||
`15:49 LEARNED_FROM Memory updated: Karti owns auth (confidence ↑)`.
|
||||
- **Bottom-right — SELECTED NODE**: click a node → kind / name / relationships count / severity / a
|
||||
one-line "why" (`Two engineers editing the same file before push — the signal git can't see.`).
|
||||
- **Legend**: engineer / file / feature / collision / intervention · collides / warns / learned_from.
|
||||
|
||||
It reads in 10 seconds because it is **sparse, color-coded, and tells the loop story** with the rails +
|
||||
stream, not just a node blob. (The full mock HTML/CSS is reproduced in **Appendix A** — port its
|
||||
structure to light shadcn.)
|
||||
|
||||
### B. The shipped light version = what's wrong (the thing to fix)
|
||||
|
||||
Same data, but: a **hairball** — every engineer fans red `collides` edges to ~6 collision triangles
|
||||
(all labeled `sync PR`); **file labels overlap** in a dim middle column; the graph uses a **static
|
||||
deterministic column layout** (`x` by kind, `y` evenly spread) so it looks dead/lifeless; and it is
|
||||
**missing the LEARNING LOOP rail and the ACTIVITY STREAM** entirely — it's just a metrics rail + the bare
|
||||
graph + a selected-node panel. (Two already-fixed-on-branch items: garbage collision labels
|
||||
`infra/README.md### Running the git watcher` and full-path bleed — see PR #7 / commit `a21a289`,
|
||||
`shortLabel` in `live.ts`. Build on top of that, don't redo it.)
|
||||
|
||||
---
|
||||
|
||||
## 2. The gap to close (light vs mock)
|
||||
|
||||
| Mock has | Light version | Action |
|
||||
| ---------------------------------------- | ------------------------------- | ---------------------------------- |
|
||||
| Workflow metrics rail | ✅ has it (3 metrics) | keep; restyle to match |
|
||||
| **Learning loop rail (observe→…→adapt)** | ❌ missing | **build it** (needs live counts) |
|
||||
| **Activity stream feed** | ❌ missing | **build it** (needs an event feed) |
|
||||
| Selected-node panel | ✅ has it | keep |
|
||||
| **Dynamic / animated graph** | ❌ static columns | **replace the layout** |
|
||||
| Sparse, lit "risk path" | partial (Risk-path mode exists) | improve emphasis + spacing |
|
||||
| Legend | ✅ | keep |
|
||||
|
||||
---
|
||||
|
||||
## 3. Current architecture (build on this — do NOT rewrite the materializer)
|
||||
|
||||
**Backend (good, keep):**
|
||||
|
||||
- `backend/src/graph/live.ts` — `materializePodGraph(podId)`: builds the graph from the real Mongo
|
||||
collections (`pods`, `engineer_states`, `observations`, `collisions`, `interventions`, `outcomes`).
|
||||
It already de-noises hard: collapses collisions by `memorySignature`, caps to 8, collapses
|
||||
interventions to one per collision, filters junk files (`isFilePath`), prunes test-artifact engineers
|
||||
(`ENGINEER_NOISE`), caps files to 9, short labels (`shortLabel`). Output is ~20 clean nodes for
|
||||
`demo-pod`. **This is solid — extend it, don't replace it.**
|
||||
- `backend/src/graph/store.ts` — `loadPodGraph(podId)`: live materializer → seeded `team_model.graph`
|
||||
→ demo fallback (`createDemoPodGraph`). Plus `reachFrom` (`$graphLookup`).
|
||||
- Route: `GET /api/pods/:id/graph` returns `PodGraph` (also `/graph/reach/:node`).
|
||||
- `backend/src/memory/db.ts` — `collections()`, `getGitStates(podId)`, `getDb()`.
|
||||
- WS bus: `backend/src/server.ts` hosts `ws /api/events` (the agent + `/api/outcome` broadcast here).
|
||||
|
||||
**Frontend (this is where the work is):**
|
||||
|
||||
- `frontend/src/components/GraphView.tsx` — **the thing you redesign** (~90% of the work). Currently:
|
||||
fetches `/api/pods/:id/graph`, renders a bespoke SVG with the static column layout, Risk/Learning/Whole
|
||||
toggles, a metrics rail, a selected-node panel, a legend. Composed from shadcn primitives (`Button`,
|
||||
`Badge`) + Tailwind utilities. Theme-aware via shadcn tokens.
|
||||
- `frontend/src/lib/graph.ts` — `fetchPodGraph(podId)`.
|
||||
- Opened from each `PodCard`'s `⋯` menu → "Team memory" (`onOpenGraph(pod.id)` in
|
||||
`frontend/src/App.tsx`). It is a conditional render (no route).
|
||||
|
||||
**Data contract** (`shared/src/graph.ts`):
|
||||
|
||||
```ts
|
||||
PodGraph = { podId, generatedAt, nodes: PodGraphNode[], edges: PodGraphEdge[], metrics: PodGraphMetric[] }
|
||||
PodGraphNode = { id, kind, label, summary, weight 0..1, status: 'stable'|'active'|'risk'|'learned', x, y }
|
||||
// kind: 'engineer'|'feature'|'file'|'collision'|'intervention'
|
||||
PodGraphEdge = { id, source, target, kind, label, strength 0..1 }
|
||||
// kind: 'owns'|'editing'|'touches'|'collides'|'warns'|'learned_from'
|
||||
PodGraphMetric = { label, value, detail }
|
||||
```
|
||||
|
||||
**Theme / components (HARD RULE):** the app is **light shadcn**, built from the **ruixen registry** —
|
||||
add primitives with `npx shadcn@latest add "https://ruixen.com/r/[component]"` and compose from
|
||||
`@/components/ui/*` (`Button`, `Badge`, `Card`, `Tabs`, `ToggleGroup`, etc.) using the design tokens
|
||||
(`var(--card)` / `--foreground` / `--muted-foreground` / `--border`, `--chart-1..5`). Only the SVG/canvas
|
||||
graph is bespoke. Match `frontend/src/App.tsx`'s `StatPill`/`BriefLine` utility patterns.
|
||||
|
||||
---
|
||||
|
||||
## 4. Target design (build this)
|
||||
|
||||
A light shadcn page with the **mock's structure**:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
│ Header: "Team memory · What PodMan learned · <pod>" [← Pods] │
|
||||
├───────────────────────────────────────────────────────────────────────┤
|
||||
│ Toggles: Risk path | Learning edges | Whole graph (keep) │
|
||||
├──────────────┬──────────────────────────────────┬─────────────────────┤
|
||||
│ WORKFLOW │ │ LEARNING LOOP │
|
||||
│ METRICS │ DYNAMIC GRAPH CANVAS │ observe→store→ │
|
||||
│ (cards) │ (force-directed + animated) │ predict→outcome→ │
|
||||
│ │ │ adapt (active pulses)│
|
||||
├──────────────┴──────────────────────────────────┴─────────────────────┤
|
||||
│ ACTIVITY STREAM (time-tagged feed) │ SELECTED NODE (detail) │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Light shadcn** throughout (theme-aware; follows dark mode if the app ever toggles). Keep the
|
||||
geometric **node-shape + color encoding** (it's the legible part) but on light surfaces with the
|
||||
app's hues (engineer blue `#2563eb`, file slate outline `#475569`, feature amber `#d97706`,
|
||||
collision red `#dc2626`, intervention violet `#7c3aed`; edges: collides red, warns amber,
|
||||
learned_from dashed violet, owns blue, editing slate, touches faint slate).
|
||||
- **Default to "Risk path"**: light the collision→intervention→`learned_from` chain; dim the rest.
|
||||
|
||||
---
|
||||
|
||||
## 5. Make the graph DYNAMIC (the headline new requirement)
|
||||
|
||||
The static column layout (`live.ts` `layout()` sets `x`/`y` by kind) looks dead. Replace the frontend
|
||||
rendering with a **dynamic** graph. Pick one (recommended order):
|
||||
|
||||
1. **`d3-force` force-directed (recommended).** Add `d3-force` (small). Run a force simulation on the
|
||||
`PodGraph` nodes/edges: link force (by `edge.strength`), charge/repulsion, center, collision radius
|
||||
(by `node.weight`). Render nodes/edges as SVG, update positions per tick. Make nodes **draggable**
|
||||
(pin on drag). Animate new nodes/edges fading in on data refresh, and the `learned_from` dashed
|
||||
stroke animating. Ignore the server's `x`/`y` (or use them as initial positions). Keep node shapes.
|
||||
2. `react-force-graph` / `force-graph` (canvas) — heavier, faster for big graphs; overkill at ~20 nodes
|
||||
but fine.
|
||||
3. A custom animated **layered** layout (engineers → files → collisions → interventions columns, but
|
||||
with curved edges, eased position transitions on refresh, and gentle idle motion). Lighter-weight
|
||||
than d3-force; still feels alive if you animate transitions.
|
||||
|
||||
**Realtime/dynamic data:** poll `GET /api/pods/:id/graph` every ~5s and **animate the diff** between
|
||||
snapshots (don't hard-replace). Optionally subscribe to `ws /api/events` for instant nudges. New
|
||||
collisions/interventions should visibly animate in; the `learned_from` edge + gold node should pop on a
|
||||
new accepted outcome.
|
||||
|
||||
**De-hairball:** even force-directed, ~6 collisions × 3 engineers = many `collides` edges. Mitigate:
|
||||
bundle/curve edges, lower non-risk edge opacity, default to Risk-path emphasis, size nodes by `weight`,
|
||||
and keep label collision-avoidance (offset labels, hide on overlap, show on hover/select).
|
||||
|
||||
---
|
||||
|
||||
## 6. Data for the new panels (extend the materializer or add endpoints)
|
||||
|
||||
The mock's **Learning Loop** and **Activity Stream** need data the current `PodGraph` doesn't carry. Two
|
||||
options: (a) extend `materializePodGraph` to also return `loop` + `activity`, or (b) add small endpoints.
|
||||
Recommended: extend the return type (additive to `shared/src/graph.ts`).
|
||||
|
||||
- **Learning loop counts** (`observe→store→predict→outcome→adapt`):
|
||||
- observe = recent `observations` count (e.g. last 60s) / rate
|
||||
- store = `memory_vectors` or `collisions.embedding` count (Voyage vectors)
|
||||
- predict = open `collisions` (distinct signatures) count
|
||||
- outcome = `outcomes` accepted vs dismissed counts
|
||||
- adapt = `team_model.ownership` entries / learned owners count
|
||||
- mark the "active" stage = the most recent activity.
|
||||
- **Activity stream**: merge + time-sort recent events from `collisions.detectedAt`,
|
||||
`interventions.createdAt`, `outcomes.recordedAt`, `engineer_states.gitUpdatedAt` → a typed feed
|
||||
`{ at, kind: 'editing'|'collision'|'warns'|'outcome'|'learned_from', text }`. Cap to ~8 most recent.
|
||||
|
||||
(`backend/src/memory/db.ts` `collections()` gives you `observations/collisions/interventions/outcomes`;
|
||||
`getGitStates` gives engineer_states; `team_model` is `db.collection('team_model')`.)
|
||||
|
||||
---
|
||||
|
||||
## 7. Files to touch
|
||||
|
||||
- **`frontend/src/components/GraphView.tsx`** — the redesign (force-directed graph + 3-panel layout +
|
||||
learning-loop rail + activity stream). May split into `GraphCanvas.tsx`, `LearningLoop.tsx`,
|
||||
`ActivityStream.tsx`, `MetricsRail.tsx`.
|
||||
- **`frontend/src/lib/graph.ts`** — add fetches for loop/activity if you add endpoints.
|
||||
- **`backend/src/graph/live.ts`** (extend, don't rewrite) — emit `loop` + `activity` in the result;
|
||||
keep all the de-noise.
|
||||
- **`shared/src/graph.ts`** — add `loop`/`activity` types to `PodGraph` (additive).
|
||||
- **deps** — `d3-force` (+ `@types/d3-force`) via pnpm in `frontend`.
|
||||
- Possibly add a ruixen primitive (e.g. `timeline`, `stepper`) via the shadcn CLI if one fits.
|
||||
|
||||
---
|
||||
|
||||
## 8. Constraints & gotchas (READ — these will bite you)
|
||||
|
||||
- **The materializer is good — do not rewrite it.** It already de-noises (caps, collapse-by-signature,
|
||||
engineer/file filters, short labels). The bad UI is the **frontend layout/render**, not the data.
|
||||
- **PWA service worker caches aggressively** — after any deploy, hard-refresh (Cmd-Shift-R) or test in a
|
||||
private window, or you'll think nothing changed.
|
||||
- **Deploy = merge to `main`** (DO `deploy_on_push: true`). `main` is shared by ~4 engineers and moves
|
||||
fast. Work on a branch, open a PR, merge. Don't push to `main` directly.
|
||||
- **`learned_from` "money" edge won't render on `demo-pod`** right now — its one accepted outcome is
|
||||
orphaned (points at a collision deleted by test churn). It needs **one intact accept flow** (real
|
||||
collision → intervention → someone clicks Accept) to draw. To demo, seed a clean chain or clear test
|
||||
docs (writes to shared Atlas — confirm scope first).
|
||||
- **Atlas creds rotate frequently** — `podman/.env`'s `MONGODB_URI` may be stale; the **deployed env**
|
||||
has the working one. If local Mongo auth fails, that's why.
|
||||
- **Build verification**: some sandboxes can't run `pnpm`/`vite`/shadcn deps (`lucide-react`,
|
||||
`@radix-ui`). Verify the frontend with `pnpm build` in a real env or CI before merging. Backend
|
||||
typecheck excludes uninstalled `ws`/`sharp`/`@livekit/rtc-node` noise.
|
||||
- **Compose from ruixen/shadcn primitives** (`npx shadcn add ruixen.com/r/[component]`,
|
||||
`@/components/ui/*`); only the SVG/canvas graph is bespoke. Match `StatPill`/`BriefLine` in `App.tsx`.
|
||||
- **Light theme + tokens** — never hardcode dark colors for chrome; use `var(--card)/--foreground/…`.
|
||||
Keep fixed semantic hues only for the node/edge kind encoding.
|
||||
|
||||
---
|
||||
|
||||
## 9. Acceptance criteria
|
||||
|
||||
- Light Team-memory page matches the mock's structure: **metrics rail + dynamic graph + learning-loop
|
||||
rail + activity stream + selected-node panel + legend**.
|
||||
- **Graph is dynamic**: force-directed (or animated layered), **draggable**, **animates** new
|
||||
nodes/edges and refresh transitions; **no overlapping labels, no hairball**.
|
||||
- Reads in 10s; the **risk/money path is obvious** by default.
|
||||
- **Light shadcn** theme, theme-aware; composed from ruixen primitives.
|
||||
- Uses **real data** from `materializePodGraph`; graceful demo fallback when empty.
|
||||
- `pnpm build` + typechecks pass; deploys; verified after a hard-refresh.
|
||||
|
||||
---
|
||||
|
||||
## 10. Suggested first moves for the new session
|
||||
|
||||
1. Read this file + `docs/graph.md` + `docs/live-ui-spec.md` (R1/R2 sections) + `CLAUDE.md`.
|
||||
2. `git fetch`; branch off `main` (or `feat/live-graph-glue`, which has the latest graph work).
|
||||
3. Hit the live data once: `curl https://165-22-129-249.sslip.io/api/pods/demo-pod/graph` — that's the
|
||||
real `PodGraph` you'll render.
|
||||
4. Build a `d3-force` `GraphCanvas` first (replace the static layout), get it draggable + animated.
|
||||
5. Add `LearningLoop` + `ActivityStream` (extend the materializer to feed them).
|
||||
6. Polish to the mock; `pnpm build`; PR → main → redeploy → hard-refresh.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — the dark mock (reference structure to port to light)
|
||||
|
||||
The mock is a single dark card. Structure + the exact content to reproduce (in light shadcn):
|
||||
|
||||
- Header: brand glyph (blue square + amber circle + red triangle + outlined square) + `PODMAN /
|
||||
CONTINUAL-LEARNING OBSERVATORY` + `● LIVE · POD demo-pod`.
|
||||
- Grid `180px 1fr 196px`: **metrics rail** | **graph** | **learning-loop rail**.
|
||||
- Metrics cards: big `Archivo`-weight numeral, uppercase tracked label, muted detail, colored
|
||||
left-accent (blue/red/yellow/violet/green).
|
||||
- Graph: SVG, geometric node shapes by kind, color-coded edges, one lit risk path, dim others; click a
|
||||
node → highlight its incident edges + neighbors, fill the selected-node panel.
|
||||
- Learning-loop rail: 5 bordered steps with number + UPPERCASE title + muted sub; the active step has a
|
||||
pulsing left bar; `↓` arrows between.
|
||||
- Legend row (node kinds + edge kinds).
|
||||
- Activity stream (time + colored tag + text) and selected-node panel below.
|
||||
|
||||
Palette used (port to shadcn tokens for chrome; keep these as node/edge hues):
|
||||
`bg #0c0c0e`, `panel #141417`, `line #2a2a31`, `paper/text #ECE7DA`, `muted #8d897e`,
|
||||
`blue #3B5BFF`, `red #E2403A`, `amber #F6C445`, `violet #8b6cff`, `green #46c07a`.
|
||||
For light: chrome → `var(--card)/--foreground/--border/--muted-foreground`; node/edge hues →
|
||||
blue `#2563eb`, slate `#475569`, amber `#d97706`, red `#dc2626`, violet `#7c3aed` (light-readable).
|
||||
|
||||
> The dark mock was a `show_widget` demo (not a saved file). If you want the literal HTML/CSS, ask the
|
||||
> user to paste it, or reconstruct from this appendix — the **structure + content above is the spec**.
|
||||
> Goal: same structure, same legibility, **light theme + dynamic graph**.
|
||||
@@ -0,0 +1,483 @@
|
||||
# Team Memory Graph - Codex Redesign Brief
|
||||
|
||||
> **Read this whole file before touching code.**
|
||||
> This is a fresh-session handoff for rebuilding PodMan's live **Team memory** graph UI.
|
||||
> The goal is not to tweak labels or add another filter. The goal is to make the
|
||||
> real-data light UI as polished, legible, and functional as the original dark
|
||||
> Bauhaus mock, while keeping the graph backed by live MongoDB data.
|
||||
|
||||
## 0. Mission
|
||||
|
||||
PodMan's Team memory view should make the recursive self-improvement loop visible:
|
||||
who is working, which files overlap, where collisions happen, which intervention was
|
||||
sent, and what PodMan learned from accepted outcomes.
|
||||
|
||||
The current light real-data implementation proves the backend can materialize a graph,
|
||||
but the UI does not yet tell the story. It still reads as a static node-link diagram:
|
||||
edges dominate, labels collide, the layout feels fixed, and the important loop
|
||||
`observe -> store -> predict -> outcome -> adapt` is not visible.
|
||||
|
||||
Rebuild the Team memory experience so it has the narrative clarity of the dark
|
||||
Bauhaus mock, in the app's light shadcn/ruixen visual system, with a dynamic graph
|
||||
that animates and responds to live data changes.
|
||||
|
||||
## 1. Current State
|
||||
|
||||
Branch context:
|
||||
|
||||
- Work is on `feat/live-graph-glue`.
|
||||
- The live graph backend exists and should be reused.
|
||||
- A PR for the live graph glue already exists, and later commits have continued
|
||||
refining readability.
|
||||
- The current file requested by the user is this document:
|
||||
`codex_team_memory_redisgn.md`.
|
||||
|
||||
Important existing files:
|
||||
|
||||
- `backend/src/graph/live.ts`
|
||||
Builds `PodGraph` from real Mongo collections:
|
||||
`pods`, `engineer_states`, `observations`, `collisions`, `interventions`,
|
||||
and `outcomes`.
|
||||
- `backend/src/graph/store.ts`
|
||||
Loads live graph first, then seeded `team_model.graph`, then demo fallback.
|
||||
- `shared/src/graph.ts`
|
||||
Defines the graph contract.
|
||||
- `frontend/src/components/GraphView.tsx`
|
||||
Current frontend graph rendering. This is the main file to redesign.
|
||||
- `frontend/src/lib/graph.ts`
|
||||
Fetches the graph.
|
||||
- `frontend/src/App.tsx` and `frontend/src/components/PodCard.tsx`
|
||||
Open Team memory per pod.
|
||||
|
||||
Do **not** start by rewriting the backend materializer. It already does the most
|
||||
important real-data work: filtering noisy files, collapsing repeated collisions,
|
||||
capping graph size, shortening labels, and pruning test engineers. The redesign is
|
||||
primarily a frontend information-architecture and interaction problem.
|
||||
|
||||
## 2. Reference Screens
|
||||
|
||||
### Current Light Real-Data UI
|
||||
|
||||
The light UI is technically real and connected to live data, but it fails visually.
|
||||
|
||||
Observed problems:
|
||||
|
||||
- The graph is too static and column-like.
|
||||
- Red collision edges dominate the canvas.
|
||||
- Labels overlap and fight for attention.
|
||||
- Interventions repeat as a row of identical diamonds.
|
||||
- The right panel says "It learned" but does not explain the actual workflow state.
|
||||
- The screen lacks an activity stream.
|
||||
- The screen lacks the explicit learning-loop rail from the dark mock.
|
||||
- The viewer cannot quickly answer:
|
||||
- What happened?
|
||||
- Who collided?
|
||||
- What did PodMan do?
|
||||
- Did the team accept it?
|
||||
- What changed in memory?
|
||||
|
||||
The current light version proves data plumbing. It does not yet work as a demo
|
||||
surface.
|
||||
|
||||
### Dark Bauhaus Mock
|
||||
|
||||
The dark mock is the quality target. Do not copy the dark palette wholesale, but
|
||||
copy the structure, density, and storytelling.
|
||||
|
||||
The mock has:
|
||||
|
||||
- A strong title bar:
|
||||
`PODMAN / CONTINUAL-LEARNING OBSERVATORY`
|
||||
- A live status indicator:
|
||||
`LIVE - POD demo-pod`
|
||||
- A left metrics rail:
|
||||
workflow metrics as compact, high-contrast cards.
|
||||
- A center graph:
|
||||
sparse, geometric, readable, with one primary path emphasized.
|
||||
- A right learning-loop rail:
|
||||
`Observe -> Store -> Predict -> Outcome -> Adapt`
|
||||
- A bottom activity stream:
|
||||
timestamped events with type badges.
|
||||
- A selected-node detail panel:
|
||||
kind, relationships, severity, explanation.
|
||||
- A legend:
|
||||
node shapes and edge colors.
|
||||
|
||||
The mock works because it is not just a graph. It is an observatory. It tells the
|
||||
loop story.
|
||||
|
||||
## 3. Product Goal
|
||||
|
||||
Team memory should be the "it learned" surface.
|
||||
|
||||
In a 10-second demo, a viewer should understand:
|
||||
|
||||
1. Two engineers are converging on the same file.
|
||||
2. PodMan detected the risk before a push.
|
||||
3. PodMan suggested an intervention.
|
||||
4. The team accepted or dismissed the intervention.
|
||||
5. PodMan retained that outcome as memory.
|
||||
6. Future collisions become more informed.
|
||||
|
||||
The graph should support that story, not overwhelm it.
|
||||
|
||||
## 4. Target Layout
|
||||
|
||||
Build a light shadcn page with the same conceptual structure as the dark mock.
|
||||
|
||||
```text
|
||||
+--------------------------------------------------------------------------+
|
||||
| Header: Team memory - What PodMan learned - <pod> [<- Pods] |
|
||||
+--------------------------------------------------------------------------+
|
||||
| Mode controls: Risk path | Learning edges | Whole graph |
|
||||
+---------------+--------------------------------------+-------------------+
|
||||
| Workflow | | Learning loop |
|
||||
| metrics | Dynamic graph canvas | Observe |
|
||||
| cards | | Store |
|
||||
| | | Predict |
|
||||
| | | Outcome |
|
||||
| | | Adapt |
|
||||
+---------------+--------------------------------------+-------------------+
|
||||
| Activity stream | Selected node details |
|
||||
+--------------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
Required panels:
|
||||
|
||||
- **Header**
|
||||
- Pod name / id.
|
||||
- Live/generated timestamp.
|
||||
- Back to pods action.
|
||||
- **Mode controls**
|
||||
- Risk path.
|
||||
- Learning edges.
|
||||
- Whole graph.
|
||||
- **Workflow metrics rail**
|
||||
- Learned owners.
|
||||
- Open risk paths.
|
||||
- Accept rate.
|
||||
- Optional: observations, interventions, memory vectors if available.
|
||||
- **Dynamic graph canvas**
|
||||
- Force-directed or animated layered graph.
|
||||
- Geometric node shapes.
|
||||
- Curved or bundled edges.
|
||||
- Labels should not overlap by default.
|
||||
- Hover/select reveals full details.
|
||||
- **Learning loop rail**
|
||||
- Observe.
|
||||
- Store.
|
||||
- Predict.
|
||||
- Outcome.
|
||||
- Adapt.
|
||||
- Active/current step should pulse or be highlighted.
|
||||
- **Activity stream**
|
||||
- Recent editing, collision, warning, outcome, learned events.
|
||||
- Compact rows with timestamp + colored type badge.
|
||||
- **Selected node**
|
||||
- Default state explains the loop.
|
||||
- Selected state shows node kind, name, relationships, severity/status, and
|
||||
why this node matters.
|
||||
|
||||
## 5. Visual Direction
|
||||
|
||||
Use the app's light shadcn/ruixen design system for chrome.
|
||||
|
||||
Hard rules:
|
||||
|
||||
- Use `@/components/ui/*` primitives where possible.
|
||||
- If a primitive is missing, add it through:
|
||||
`npx shadcn@latest add "https://ruixen.com/r/[component]"`
|
||||
- Do not make the entire UI a bespoke CSS island.
|
||||
- The graph canvas itself may be bespoke SVG/canvas.
|
||||
- The rest should be composed from cards, badges, buttons, tabs/toggles, and
|
||||
utility classes consistent with `App.tsx`.
|
||||
|
||||
Keep semantic graph colors:
|
||||
|
||||
- Engineer: blue.
|
||||
- File: slate outline.
|
||||
- Feature: amber circle.
|
||||
- Collision: red triangle.
|
||||
- Intervention: violet diamond.
|
||||
- `collides`: red edge.
|
||||
- `warns`: amber/orange edge.
|
||||
- `learned_from`: dashed violet edge.
|
||||
- `owns`: blue edge.
|
||||
- `editing` / `touches`: muted slate.
|
||||
|
||||
Use light surfaces:
|
||||
|
||||
- Background: app background token.
|
||||
- Panels: `card`.
|
||||
- Borders: `border`.
|
||||
- Text: `foreground`.
|
||||
- Supporting copy: `muted-foreground`.
|
||||
|
||||
The result should feel like the dark mock translated into the app's light command
|
||||
center, not a random analytics dashboard.
|
||||
|
||||
## 6. Dynamic Graph Requirement
|
||||
|
||||
The current graph is too static. Replace or augment the static column layout.
|
||||
|
||||
Preferred implementation:
|
||||
|
||||
- Use `d3-force` in the frontend.
|
||||
- Initialize nodes from server `x/y` when useful, but let the simulation settle.
|
||||
- Use:
|
||||
- link force by edge strength.
|
||||
- charge force for separation.
|
||||
- center force.
|
||||
- collision force based on node radius.
|
||||
- optional x/y bias by kind to preserve rough story flow.
|
||||
- Make nodes draggable.
|
||||
- Preserve node shape encoding.
|
||||
- Animate:
|
||||
- new nodes fading/scaling in.
|
||||
- new edges drawing/fading in.
|
||||
- `learned_from` dashed edge flowing or pulsing.
|
||||
- active collision/intervention pulse.
|
||||
|
||||
If `d3-force` is too much for the current branch, use an animated layered layout:
|
||||
|
||||
- Engineers left.
|
||||
- Files mid-left.
|
||||
- Collisions center/right.
|
||||
- Interventions right.
|
||||
- Curved edges.
|
||||
- Smooth transitions between graph snapshots.
|
||||
- Gentle idle motion only if it helps.
|
||||
|
||||
Do not leave the final version as static fixed columns.
|
||||
|
||||
## 7. De-Hairball Rules
|
||||
|
||||
Default screen should show the risk path, not every possible relationship.
|
||||
|
||||
Rules:
|
||||
|
||||
- Default mode: `Risk path`.
|
||||
- Whole graph can exist, but it is not the demo default.
|
||||
- Dim non-selected/non-risk edges aggressively.
|
||||
- Use curved edges or edge bundling.
|
||||
- Hide low-priority labels until hover/select.
|
||||
- Prefer file basename/short path on canvas.
|
||||
- Put full path in selected-node panel.
|
||||
- Group repeated collisions by signature.
|
||||
- Cap visible collisions/interventions for demo readability.
|
||||
- Preserve all data in the payload; choose a readable default projection.
|
||||
|
||||
The graph is not an exhaustive database browser. It is a story-first visualization.
|
||||
|
||||
## 8. Data Model To Use
|
||||
|
||||
Current `PodGraph` contract:
|
||||
|
||||
```ts
|
||||
interface PodGraph {
|
||||
podId: string;
|
||||
generatedAt: string;
|
||||
nodes: PodGraphNode[];
|
||||
edges: PodGraphEdge[];
|
||||
metrics: PodGraphMetric[];
|
||||
}
|
||||
```
|
||||
|
||||
Node kinds:
|
||||
|
||||
- `engineer`
|
||||
- `file`
|
||||
- `feature`
|
||||
- `collision`
|
||||
- `intervention`
|
||||
|
||||
Edge kinds:
|
||||
|
||||
- `owns`
|
||||
- `editing`
|
||||
- `touches`
|
||||
- `collides`
|
||||
- `warns`
|
||||
- `learned_from`
|
||||
|
||||
Statuses:
|
||||
|
||||
- `stable`
|
||||
- `active`
|
||||
- `risk`
|
||||
- `learned`
|
||||
|
||||
Existing collections behind the materializer:
|
||||
|
||||
- `pods`
|
||||
- `engineer_states`
|
||||
- `observations`
|
||||
- `collisions`
|
||||
- `interventions`
|
||||
- `outcomes`
|
||||
|
||||
Important caveat:
|
||||
|
||||
The current `demo-pod` accepted outcome chain may be orphaned from test churn.
|
||||
If `learned_from` does not show, confirm whether there is an intact:
|
||||
|
||||
```text
|
||||
collision -> intervention -> accepted outcome
|
||||
```
|
||||
|
||||
Do not assume the UI is broken until this data chain is verified.
|
||||
|
||||
## 9. Extend Data For Missing Panels
|
||||
|
||||
The current graph contract does not fully support the dark mock's learning-loop
|
||||
rail or activity stream.
|
||||
|
||||
Recommended additive extension:
|
||||
|
||||
```ts
|
||||
interface PodGraphLoopStep {
|
||||
id: 'observe' | 'store' | 'predict' | 'outcome' | 'adapt';
|
||||
label: string;
|
||||
value: string;
|
||||
detail: string;
|
||||
status: 'idle' | 'active' | 'complete';
|
||||
}
|
||||
|
||||
interface PodGraphActivity {
|
||||
id: string;
|
||||
at: string;
|
||||
kind: 'editing' | 'collision' | 'warns' | 'outcome' | 'learned_from';
|
||||
label: string;
|
||||
detail: string;
|
||||
nodeId?: string;
|
||||
edgeId?: string;
|
||||
}
|
||||
|
||||
interface PodGraph {
|
||||
...
|
||||
loop?: PodGraphLoopStep[];
|
||||
activity?: PodGraphActivity[];
|
||||
}
|
||||
```
|
||||
|
||||
Possible data mappings:
|
||||
|
||||
- Observe:
|
||||
recent `observations`.
|
||||
- Store:
|
||||
stored observations / vectorized collisions / memory documents.
|
||||
- Predict:
|
||||
distinct live collisions.
|
||||
- Outcome:
|
||||
accepted vs dismissed outcomes.
|
||||
- Adapt:
|
||||
learned owners / `learned_from` edges / `team_model.ownership`.
|
||||
|
||||
Activity stream source:
|
||||
|
||||
- `engineer_states.gitUpdatedAt` -> editing/git state.
|
||||
- `collisions.detectedAt` -> collision.
|
||||
- `interventions.createdAt` -> warns/intervention.
|
||||
- `outcomes.recordedAt` -> outcome.
|
||||
- accepted real outcome -> learned_from/adapt event.
|
||||
|
||||
Cap activity rows to 8-10.
|
||||
|
||||
## 10. Suggested Implementation Plan
|
||||
|
||||
1. Create a new branch from the current graph branch or latest `main`.
|
||||
2. Read:
|
||||
- this file.
|
||||
- `claude_team_memory_redesign.md`.
|
||||
- `docs/graph.md`.
|
||||
- `docs/live-ui-spec.md` if present.
|
||||
- `frontend/src/components/GraphView.tsx`.
|
||||
- `backend/src/graph/live.ts`.
|
||||
3. Add graph UI subcomponents:
|
||||
- `MetricsRail`.
|
||||
- `GraphCanvas`.
|
||||
- `LearningLoopRail`.
|
||||
- `ActivityStream`.
|
||||
- `SelectedNodePanel`.
|
||||
4. Implement the dynamic graph canvas first.
|
||||
5. Add the learning-loop rail and activity stream.
|
||||
6. Polish interaction states:
|
||||
- hover.
|
||||
- selected node.
|
||||
- selected edge/path.
|
||||
- empty/live-loading/offline.
|
||||
7. Verify with local live data.
|
||||
8. Capture screenshots at desktop and narrow widths.
|
||||
9. Run:
|
||||
- `pnpm build` or local `vite build`.
|
||||
- `tsc` for shared/backend/frontend.
|
||||
10. Open a PR. Do not push directly to `main`.
|
||||
|
||||
## 11. Acceptance Criteria
|
||||
|
||||
The redesign is acceptable only when:
|
||||
|
||||
- The default view is readable in 10 seconds.
|
||||
- The graph is dynamic, not static columns.
|
||||
- It includes metrics, graph, learning-loop rail, activity stream, selected-node
|
||||
panel, and legend.
|
||||
- The primary risk path is obvious.
|
||||
- Labels do not overlap in the default view.
|
||||
- Whole graph mode exists but can be visually denser.
|
||||
- It uses real data from the materializer.
|
||||
- It remains composed from light shadcn/ruixen primitives where possible.
|
||||
- It builds successfully.
|
||||
- It is verified after a hard refresh because the PWA can cache stale bundles.
|
||||
|
||||
## 12. What Not To Do
|
||||
|
||||
- Do not make a marketing page.
|
||||
- Do not make a generic dashboard.
|
||||
- Do not rewrite the backend materializer unless the UI needs a small additive
|
||||
field.
|
||||
- Do not return to the dark UI wholesale.
|
||||
- Do not keep the static column layout as the final answer.
|
||||
- Do not show raw full paths as always-on canvas labels.
|
||||
- Do not show every edge at equal opacity.
|
||||
- Do not hide the learning loop in copy only; it needs a visible rail or panel.
|
||||
|
||||
## 13. Demo Script The UI Should Support
|
||||
|
||||
The final UI should support this story:
|
||||
|
||||
1. Engineer A and Engineer B work in the same repo.
|
||||
2. One has unpushed changes.
|
||||
3. PodMan observes the overlap.
|
||||
4. A collision node appears and pulses.
|
||||
5. PodMan sends a sync PR / warning intervention.
|
||||
6. The intervention diamond appears.
|
||||
7. The team accepts.
|
||||
8. The outcome appears in the activity stream.
|
||||
9. A `learned_from` edge appears or pulses.
|
||||
10. The learning-loop rail advances to Adapt.
|
||||
|
||||
That is the recursive self-improvement moment. Everything else is supporting
|
||||
evidence.
|
||||
|
||||
## 14. Open Questions For The Implementer
|
||||
|
||||
- Should dynamic layout be `d3-force` or animated layered SVG?
|
||||
- Should loop/activity be added to `PodGraph` or exposed as separate endpoints?
|
||||
- Should the demo seed one intact accepted outcome chain?
|
||||
- Should `Whole graph` be hidden behind an explicit "inspect full graph" affordance?
|
||||
- Should mobile show a simplified activity-first version instead of the full graph?
|
||||
|
||||
Answer these in code comments or PR notes when implementing.
|
||||
|
||||
## 15. Final Reminder
|
||||
|
||||
The backend now has real graph glue. The UI needs to become a **live learning
|
||||
observatory**, not a static graph dump.
|
||||
|
||||
Make the light version earn the same reaction as the dark Bauhaus mock:
|
||||
|
||||
```text
|
||||
I can see what happened.
|
||||
I can see what PodMan did.
|
||||
I can see what it learned.
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
# Agent Learning Plan
|
||||
|
||||
Status: draft
|
||||
Goal: ship a visible recursive self-improvement loop without overbuilding
|
||||
|
||||
## Must-Have
|
||||
|
||||
1. Store agent runs.
|
||||
2. Store trace summaries.
|
||||
3. Store active and candidate strategy versions.
|
||||
4. Attach verifier or outcome evidence.
|
||||
5. Show one strategy improvement in the demo narrative.
|
||||
|
||||
## Build Order
|
||||
|
||||
### R1: Trace the run
|
||||
|
||||
Write one `agent_runs` record for an important coordination decision and append
|
||||
trace events for:
|
||||
|
||||
- observation
|
||||
- recall
|
||||
- prediction
|
||||
- intervention
|
||||
- outcome
|
||||
- adaptation
|
||||
|
||||
### R2: Version the strategy
|
||||
|
||||
Create an active strategy version for one of:
|
||||
|
||||
- collision detector threshold
|
||||
- intervention routing
|
||||
- graph discovery filter
|
||||
- card wording prompt
|
||||
|
||||
### R3: Score the outcome
|
||||
|
||||
Use the simplest verifier:
|
||||
|
||||
- accepted real collision = useful
|
||||
- dismissed = noisy
|
||||
- no response after cooldown = uncertain
|
||||
|
||||
### R4: Propose a narrow change
|
||||
|
||||
Examples:
|
||||
|
||||
- "For this exact signature, prefer sync PR card."
|
||||
- "For dismissed docs-only overlaps, suppress voice escalation."
|
||||
- "For repeated auth.ts collisions, raise severity."
|
||||
|
||||
### R5: Promote or reject
|
||||
|
||||
Promote only when evidence is strong enough. Otherwise keep the candidate as
|
||||
rejected or open.
|
||||
|
||||
## Demo Path
|
||||
|
||||
1. Show baseline strategy.
|
||||
2. Trigger a collision.
|
||||
3. Accept or dismiss the intervention.
|
||||
4. Store outcome.
|
||||
5. Show a candidate strategy update.
|
||||
6. Promote it.
|
||||
7. Trigger a similar event.
|
||||
8. Show changed behavior.
|
||||
|
||||
## Nice-to-Have
|
||||
|
||||
- Strategy comparison panel.
|
||||
- Model-generated prompt patch with verifier.
|
||||
- Vector recall over strategy history.
|
||||
- Rollback UI.
|
||||
|
||||
## Cut
|
||||
|
||||
- Full autonomous code rewriting.
|
||||
- Multi-agent strategy debates.
|
||||
- Long-term benchmark suite.
|
||||
- Training a model.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- The demo can point to a MongoDB record proving the agent changed behavior.
|
||||
- The changed behavior is visible.
|
||||
- The strategy has a parent and evidence.
|
||||
- Rejected or failed changes are not deleted.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Agent Learning Policy
|
||||
|
||||
Status: draft
|
||||
Scope: guardrails for recursive self-improvement
|
||||
|
||||
## Prime Rule
|
||||
|
||||
PodMan may improve its agent behavior only when the improvement is narrow,
|
||||
evidence-backed, versioned, and reversible.
|
||||
|
||||
## Allowed Learning
|
||||
|
||||
PodMan may learn:
|
||||
|
||||
- Which prompt version produces clearer interventions.
|
||||
- Which detector threshold reduces false positives.
|
||||
- Which routing channel gets accepted without being intrusive.
|
||||
- Which verifier best predicts user acceptance.
|
||||
- Which graph-discovery rule produces cleaner risk paths.
|
||||
|
||||
## Disallowed Learning
|
||||
|
||||
PodMan must not:
|
||||
|
||||
- Promote a strategy because the model says it is better.
|
||||
- Rewrite broad system behavior from one example.
|
||||
- Hide failures, dismissals, or rejected candidates.
|
||||
- Learn from raw screenshots, secrets, or private terminal content.
|
||||
- Turn voice into the default route.
|
||||
- Create irreversible actions without human approval.
|
||||
|
||||
## Promotion Rules
|
||||
|
||||
A candidate strategy can become active only when all are true:
|
||||
|
||||
1. It has a parent strategy version.
|
||||
2. It describes one concrete behavior change.
|
||||
3. It has a verifier plan.
|
||||
4. It has evidence from a run, outcome, or test.
|
||||
5. It improves or fixes the target metric.
|
||||
6. It does not increase user interruption without payoff.
|
||||
|
||||
## Rejection Rules
|
||||
|
||||
Reject and retain the candidate when:
|
||||
|
||||
- The verifier regresses.
|
||||
- The change is too broad.
|
||||
- The evidence is missing.
|
||||
- The candidate conflicts with privacy rules.
|
||||
- The candidate makes the demo less stable.
|
||||
|
||||
## Evidence Strength
|
||||
|
||||
| Evidence | Strength | Use |
|
||||
| --- | --- | --- |
|
||||
| Model opinion | Weak | Proposal only |
|
||||
| Trace observation | Medium | Candidate rationale |
|
||||
| Human accepted outcome | Strong | Promotion candidate |
|
||||
| Human dismissed outcome | Strong | Suppression or rejection |
|
||||
| Automated verifier | Strong | Promotion or rejection |
|
||||
| Repeated accepted exact signature | Strong | Policy confidence increase |
|
||||
|
||||
## Versioning Rules
|
||||
|
||||
- Strategy versions are immutable after promotion or rejection.
|
||||
- There is one active version per `podId + kind`.
|
||||
- A rollback activates the previous version; it does not edit history.
|
||||
- Parent-child lineage must be preserved.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- Store summaries, not raw sensitive content.
|
||||
- Prefer deterministic checks over model judgment.
|
||||
- Use exact MongoDB recall before vector recall.
|
||||
- Ask for approval before changing code or data with external effects.
|
||||
- Treat hackathon demo stability as a hard constraint.
|
||||
|
||||
## Demo Honesty
|
||||
|
||||
Seeded strategy versions are acceptable when labeled as demo-backed. Do not claim
|
||||
a strategy was learned live unless a run and outcome actually created the
|
||||
promotion evidence.
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Agent Learning Prompt
|
||||
|
||||
Use this prompt for an agent responsible for improving PodMan's own behavior.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are PodMan's agent-learning evaluator.
|
||||
|
||||
Your job is to inspect a completed agent run, identify one narrow improvement,
|
||||
define how to verify it, and decide whether to propose, promote, or reject a
|
||||
strategy change.
|
||||
|
||||
You must not claim improvement without evidence. You must not propose broad
|
||||
rewrites. Keep every change small, reversible, and tied to a run or outcome.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Current active strategy version.
|
||||
- Agent run summary.
|
||||
- Trace events.
|
||||
- Intervention outcome.
|
||||
- Verifier result.
|
||||
- Recent false positives or accepted events.
|
||||
- Current demo constraints.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Identify the target behavior.
|
||||
2. Identify the failure or success evidence.
|
||||
3. Decide whether a strategy change is warranted.
|
||||
4. Propose one narrow change.
|
||||
5. Define the verifier.
|
||||
6. Decide status: no change, candidate, promote, reject.
|
||||
7. Write a short explanation suitable for the Team memory activity stream.
|
||||
|
||||
## Output Format
|
||||
|
||||
```text
|
||||
Target
|
||||
- Strategy kind:
|
||||
- Active version:
|
||||
- Behavior under review:
|
||||
|
||||
Evidence
|
||||
- Run:
|
||||
- Outcome:
|
||||
- Verifier:
|
||||
- Confidence:
|
||||
|
||||
Decision
|
||||
- Status:
|
||||
- Proposed change:
|
||||
- Why this is narrow:
|
||||
- Risk:
|
||||
|
||||
Verifier
|
||||
- Metric:
|
||||
- Passing condition:
|
||||
- Failing condition:
|
||||
|
||||
Memory Write
|
||||
- Collection:
|
||||
- Record summary:
|
||||
- Graph/activity summary:
|
||||
```
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Exact outcomes beat model opinion.
|
||||
- Rejected candidates stay in memory.
|
||||
- No raw screenshots or secrets.
|
||||
- No broad policy change from one weak signal.
|
||||
- No voice-first behavior.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Agent Learning Spec
|
||||
|
||||
Status: draft
|
||||
Scope: how PodMan agents improve their own prompts, policies, detectors, and routing behavior
|
||||
Owner: agent learning / recursive self-improvement
|
||||
|
||||
## Purpose
|
||||
|
||||
Agent learning is the recursive self-improvement layer. It is not the same as
|
||||
team memory. Team memory learns about engineers and work. Agent learning learns
|
||||
which agent strategies produce better outcomes.
|
||||
|
||||
The demo claim:
|
||||
|
||||
1. PodMan tries a coordination strategy.
|
||||
2. The run is traced in MongoDB.
|
||||
3. A verifier or human outcome scores it.
|
||||
4. Gemini or another agent proposes a narrow strategy change.
|
||||
5. The new strategy is versioned.
|
||||
6. A later run uses the improved strategy and shows a better result.
|
||||
|
||||
## Core Objects
|
||||
|
||||
### Agent run
|
||||
|
||||
One attempt to execute a goal.
|
||||
|
||||
```text
|
||||
agent_runs
|
||||
runId
|
||||
podId
|
||||
goal
|
||||
trigger
|
||||
strategyVersionId
|
||||
status
|
||||
startedAt
|
||||
completedAt
|
||||
score
|
||||
verifierSummary
|
||||
inputRefs
|
||||
outputRefs
|
||||
```
|
||||
|
||||
Allowed `status` values:
|
||||
|
||||
```text
|
||||
running, succeeded, failed, improved, regressed, abandoned
|
||||
```
|
||||
|
||||
### Trace event
|
||||
|
||||
Append-only event log for a run.
|
||||
|
||||
```text
|
||||
agent_trace_events
|
||||
runId
|
||||
podId
|
||||
step
|
||||
phase
|
||||
eventType
|
||||
inputSummary
|
||||
outputSummary
|
||||
toolName
|
||||
error
|
||||
metrics
|
||||
createdAt
|
||||
```
|
||||
|
||||
### Strategy version
|
||||
|
||||
Versioned prompt, detector rule, policy, verifier, or routing strategy.
|
||||
|
||||
```text
|
||||
strategy_versions
|
||||
strategyVersionId
|
||||
podId
|
||||
kind
|
||||
name
|
||||
parentVersionId
|
||||
status
|
||||
summary
|
||||
promptText
|
||||
policy
|
||||
verifier
|
||||
metrics
|
||||
createdAt
|
||||
promotedAt
|
||||
```
|
||||
|
||||
Allowed `kind` values:
|
||||
|
||||
```text
|
||||
prompt, policy, detector, verifier, routing
|
||||
```
|
||||
|
||||
Allowed `status` values:
|
||||
|
||||
```text
|
||||
candidate, active, retired, rejected
|
||||
```
|
||||
|
||||
### Learning proposal
|
||||
|
||||
A candidate change before promotion.
|
||||
|
||||
```text
|
||||
learning_proposals
|
||||
proposalId
|
||||
podId
|
||||
sourceRunId
|
||||
targetKind
|
||||
parentVersionId
|
||||
proposedChange
|
||||
rationale
|
||||
verifierPlan
|
||||
status
|
||||
createdAt
|
||||
resolvedAt
|
||||
```
|
||||
|
||||
Allowed `status` values:
|
||||
|
||||
```text
|
||||
open, accepted, rejected, superseded
|
||||
```
|
||||
|
||||
## MongoDB Indexes
|
||||
|
||||
| Collection | Index | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `agent_runs` | `{ podId: 1, startedAt: -1 }` | Recent run history |
|
||||
| `agent_runs` | `{ podId: 1, strategyVersionId: 1 }` | Compare strategy performance |
|
||||
| `agent_trace_events` | `{ runId: 1, step: 1 }` | Reconstruct run |
|
||||
| `strategy_versions` | `{ podId: 1, kind: 1, status: 1 }` | Find active strategy |
|
||||
| `strategy_versions` | `{ podId: 1, createdAt: -1 }` | Version history |
|
||||
| `learning_proposals` | `{ podId: 1, status: 1 }` | Open candidate changes |
|
||||
|
||||
## Learning Loop
|
||||
|
||||
```text
|
||||
observe run -> score run -> propose change -> test candidate -> promote or reject
|
||||
```
|
||||
|
||||
Agent learning must always connect these records:
|
||||
|
||||
```text
|
||||
agent_run -> trace_events -> verifier result -> learning_proposal -> strategy_version
|
||||
```
|
||||
|
||||
## Verifier Contract
|
||||
|
||||
Every promoted strategy needs a verifier signal.
|
||||
|
||||
Allowed verifier types:
|
||||
|
||||
- Human accepted or dismissed outcome.
|
||||
- Test pass or fail result.
|
||||
- Reduced false positive rate.
|
||||
- Reduced intervention count with same or better accepted outcomes.
|
||||
- Faster successful run.
|
||||
- Better graph discovery precision.
|
||||
- Explicit demo operator approval.
|
||||
|
||||
Self-evaluation alone is not enough to promote a strategy.
|
||||
|
||||
## Relationship to Team Graph
|
||||
|
||||
Agent learning can appear in the Team memory graph as activity and loop status,
|
||||
but it should not clutter the main risk graph by default.
|
||||
|
||||
Graph discovery may show:
|
||||
|
||||
- `agent_run` activity in the stream.
|
||||
- `strategy_versions` count in the learning loop.
|
||||
- A selected-node detail saying a policy changed because a prior outcome was
|
||||
dismissed or accepted.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Every strategy change has a parent.
|
||||
- Every promoted strategy cites evidence.
|
||||
- Rejected strategies are retained with a reason.
|
||||
- Agent traces are append-only.
|
||||
- The system can answer: "What changed, why, and did it help?"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Continual Learning Plan
|
||||
|
||||
Status: draft
|
||||
Goal: prove PodMan learns from outcomes in the hackathon demo
|
||||
|
||||
## Must-Have Demo Loop
|
||||
|
||||
1. Observe two engineers touching the same file.
|
||||
2. Store the observation and git state in MongoDB.
|
||||
3. Predict a collision.
|
||||
4. Send a card or Hermes message.
|
||||
5. Record accept or dismiss outcome.
|
||||
6. Adapt `team_model`.
|
||||
7. Show the learned graph edge or changed future behavior.
|
||||
|
||||
## Build Order
|
||||
|
||||
### R1: Make exact recall reliable
|
||||
|
||||
- Normalize file paths.
|
||||
- Build stable memory signatures.
|
||||
- Look up prior accepted and dismissed outcomes.
|
||||
- Prefer exact recall over vector recall.
|
||||
|
||||
### R2: Make outcomes update memory
|
||||
|
||||
- Accepted real collision creates or strengthens ownership.
|
||||
- Accepted real collision creates `learned_from`.
|
||||
- Dismissed outcome lowers confidence or suppresses route.
|
||||
|
||||
### R3: Expose loop data to the graph
|
||||
|
||||
- Add optional loop snapshot.
|
||||
- Add optional activity stream.
|
||||
- Keep existing `PodGraph` fields stable.
|
||||
|
||||
### R4: Show the observatory
|
||||
|
||||
- Render observe/store/predict/outcome/adapt.
|
||||
- Show recent activity.
|
||||
- Make selected-node detail explain why memory changed.
|
||||
|
||||
### R5: Prepare a clean demo chain
|
||||
|
||||
- Ensure one collision -> intervention -> accepted outcome exists.
|
||||
- Ensure repeated signature recalls prior memory.
|
||||
- Verify graph shows learned ownership.
|
||||
|
||||
## Nice-to-Have
|
||||
|
||||
- Atlas Vector Search over memory summaries.
|
||||
- Confidence scoring per ownership edge.
|
||||
- Per-file memory timeline.
|
||||
- Strategy promotion tied to outcomes.
|
||||
|
||||
## Cut
|
||||
|
||||
- Raw screenshot storage.
|
||||
- Full autonomous training.
|
||||
- Broad dashboard metrics.
|
||||
- Multi-pod learning generalization.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- A judge can see what changed in memory.
|
||||
- The second similar event behaves differently.
|
||||
- Exact MongoDB records prove the loop.
|
||||
- The graph remains legible with real data.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# Continual Learning Policy
|
||||
|
||||
Status: draft
|
||||
Scope: what PodMan may learn about a team
|
||||
|
||||
## Prime Rule
|
||||
|
||||
PodMan learns coordination patterns, not personal surveillance profiles.
|
||||
|
||||
## Allowed Memory
|
||||
|
||||
PodMan may store:
|
||||
|
||||
- File and symbol ownership.
|
||||
- Active file overlap.
|
||||
- Repeated collision signatures.
|
||||
- Intervention history.
|
||||
- Accepted and dismissed outcomes.
|
||||
- Routing preferences by event type and severity.
|
||||
- Summaries of decisions relevant to future coordination.
|
||||
|
||||
## Forbidden Memory
|
||||
|
||||
PodMan must not store:
|
||||
|
||||
- Raw screenshots.
|
||||
- Screen recordings.
|
||||
- Secrets or credentials.
|
||||
- Full terminal logs.
|
||||
- Personal performance judgments.
|
||||
- Private content unrelated to the coding task.
|
||||
|
||||
## Evidence Policy
|
||||
|
||||
| Evidence | Can predict? | Can adapt memory? |
|
||||
| --- | --- | --- |
|
||||
| Vision only | Yes, low confidence | No |
|
||||
| Git watcher | Yes | No, unless repeated |
|
||||
| GitHub state | Yes | No, unless verified |
|
||||
| Accepted real outcome | Yes | Yes |
|
||||
| Dismissed outcome | Yes, for suppression | Yes, as negative signal |
|
||||
| Verifier result | Yes | Yes |
|
||||
|
||||
## Intervention Policy
|
||||
|
||||
Use the least intrusive channel:
|
||||
|
||||
1. Watch quietly.
|
||||
2. Card.
|
||||
3. Hermes message.
|
||||
4. Voice.
|
||||
|
||||
Voice is only for urgent, high-confidence, time-sensitive risks.
|
||||
|
||||
## Adaptation Policy
|
||||
|
||||
Allowed adaptations:
|
||||
|
||||
- Add learned ownership after accepted real outcome.
|
||||
- Raise confidence for repeated accepted signatures.
|
||||
- Lower confidence for dismissed signatures.
|
||||
- Prefer the previously accepted intervention kind.
|
||||
- Suppress repeated low-value warnings.
|
||||
|
||||
Disallowed adaptations:
|
||||
|
||||
- Broad threshold changes from one example.
|
||||
- Treating vector similarity as proof.
|
||||
- Hiding dismissals.
|
||||
- Making interruption more aggressive without evidence.
|
||||
|
||||
## Retention Policy
|
||||
|
||||
Keep:
|
||||
|
||||
- Outcomes.
|
||||
- Signatures.
|
||||
- Team model memory.
|
||||
- Strategy metrics.
|
||||
|
||||
Summarize or expire:
|
||||
|
||||
- Old observations.
|
||||
- Low-confidence vision-only events.
|
||||
- Detailed trace text.
|
||||
|
||||
Delete immediately:
|
||||
|
||||
- Secrets.
|
||||
- Accidental raw sensitive captures.
|
||||
|
||||
## Demo Policy
|
||||
|
||||
Seeded data is acceptable only if the demo script is honest about it. Live
|
||||
learning requires a live or staged outcome write that visibly updates the graph
|
||||
or future decision.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Continual Learning Prompt
|
||||
|
||||
Use this prompt for the agent that decides what PodMan should remember from a
|
||||
coordination event.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are PodMan's continual-learning memory agent.
|
||||
|
||||
Your job is to inspect observations, collisions, interventions, and outcomes,
|
||||
then decide what team memory should be updated. You must separate observed
|
||||
facts, inferred risks, human outcomes, and durable learned memory.
|
||||
|
||||
Do not claim something was learned unless an accepted real outcome, verifier, or
|
||||
human label supports it.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Pod id.
|
||||
- Recent engineer states.
|
||||
- Recent observations.
|
||||
- Candidate collision.
|
||||
- Prior exact-signature memory.
|
||||
- Intervention record.
|
||||
- Outcome record.
|
||||
- Current team model.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Normalize file and symbol.
|
||||
2. Build exact signature.
|
||||
3. Check prior accepted and dismissed outcomes.
|
||||
4. Classify the current event.
|
||||
5. Decide whether memory should change.
|
||||
6. Emit the graph impact.
|
||||
7. Write a short explanation.
|
||||
|
||||
## Output Format
|
||||
|
||||
```text
|
||||
Event
|
||||
- Signature:
|
||||
- Engineers:
|
||||
- File:
|
||||
- Symbol:
|
||||
- Evidence:
|
||||
|
||||
Prior Memory
|
||||
- Accepted matches:
|
||||
- Dismissed matches:
|
||||
- Ownership:
|
||||
|
||||
Decision
|
||||
- Memory action:
|
||||
- Confidence:
|
||||
- Reason:
|
||||
|
||||
Graph Impact
|
||||
- Nodes:
|
||||
- Edges:
|
||||
- Activity text:
|
||||
|
||||
Safety
|
||||
- Sensitive data present:
|
||||
- Redaction needed:
|
||||
```
|
||||
|
||||
## Memory Actions
|
||||
|
||||
Allowed actions:
|
||||
|
||||
- no_change
|
||||
- strengthen_signature
|
||||
- weaken_signature
|
||||
- create_learned_owner
|
||||
- update_route_preference
|
||||
- suppress_signature
|
||||
- request_human_label
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- Exact recall before vector recall.
|
||||
- Dismissals are learning signals.
|
||||
- `learned_from` requires accepted real outcome.
|
||||
- Store summaries, not raw screen content.
|
||||
- Prefer less intrusive future behavior when uncertain.
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# Continual Learning Spec
|
||||
|
||||
Status: draft
|
||||
Scope: how PodMan learns team memory from live work and outcomes
|
||||
Owner: continual learning / Team memory
|
||||
|
||||
## Purpose
|
||||
|
||||
Continual learning is the product proof that PodMan gets more useful from use.
|
||||
It learns team-level coordination memory: ownership, repeated collisions,
|
||||
accepted interventions, dismissed noise, and preferred routing.
|
||||
|
||||
The visible loop:
|
||||
|
||||
```text
|
||||
observe -> store -> predict -> outcome -> adapt
|
||||
```
|
||||
|
||||
## Source Collections
|
||||
|
||||
### `engineer_states`
|
||||
|
||||
Latest per-engineer state from vision and local git.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `podId`
|
||||
- `name`
|
||||
- `currentFile`
|
||||
- `changedFiles`
|
||||
- `branch`
|
||||
- `confidence`
|
||||
- `visionUpdatedAt`
|
||||
- `gitUpdatedAt`
|
||||
- `updatedAt`
|
||||
|
||||
### `observations`
|
||||
|
||||
Structured perception events.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `podId`
|
||||
- `engineerId`
|
||||
- `currentFile`
|
||||
- `symbol`
|
||||
- `activity`
|
||||
- `confidence`
|
||||
- `observedAt`
|
||||
|
||||
### `collisions`
|
||||
|
||||
Predicted risk events.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `id`
|
||||
- `podId`
|
||||
- `file`
|
||||
- `symbol`
|
||||
- `engineers`
|
||||
- `severity`
|
||||
- `status`
|
||||
- `memorySignature`
|
||||
- `detectedAt`
|
||||
|
||||
### `interventions`
|
||||
|
||||
Actions PodMan sent or suggested.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `id`
|
||||
- `podId`
|
||||
- `collisionId`
|
||||
- `kind`
|
||||
- `channel`
|
||||
- `message`
|
||||
- `suggestedAction`
|
||||
- `createdAt`
|
||||
|
||||
### `outcomes`
|
||||
|
||||
Human or verifier supervision.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `id`
|
||||
- `podId`
|
||||
- `interventionId`
|
||||
- `collisionId`
|
||||
- `accepted`
|
||||
- `wasRealCollision`
|
||||
- `learnedOwner`
|
||||
- `recordedAt`
|
||||
|
||||
### `team_model`
|
||||
|
||||
Durable pod memory.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `podId`
|
||||
- `graph`
|
||||
- `ownership`
|
||||
- `collisionSignatures`
|
||||
- `interventionPolicy`
|
||||
- `updatedAt`
|
||||
|
||||
### `memory_vectors`
|
||||
|
||||
Optional semantic recall. Exact recall comes first.
|
||||
|
||||
Key fields:
|
||||
|
||||
- `podId`
|
||||
- `sourceKind`
|
||||
- `sourceId`
|
||||
- `text`
|
||||
- `embedding`
|
||||
- `embeddingModel`
|
||||
- `tags`
|
||||
|
||||
## Learning Rules
|
||||
|
||||
### Observe
|
||||
|
||||
Write structured evidence from vision, git, GitHub, and agent traces.
|
||||
|
||||
### Store
|
||||
|
||||
Persist source records and materialized summaries. Do not store raw screenshots
|
||||
or recordings.
|
||||
|
||||
### Predict
|
||||
|
||||
Create a collision when multiple engineers converge on the same normalized file
|
||||
or symbol and at least one signal shows active or unpushed work.
|
||||
|
||||
### Outcome
|
||||
|
||||
Record whether the intervention was accepted, dismissed, real, or false.
|
||||
|
||||
### Adapt
|
||||
|
||||
Only accepted real outcomes can create `learned_from` graph edges. Dismissals
|
||||
adapt suppression, routing, or confidence.
|
||||
|
||||
## Exact Signature
|
||||
|
||||
Use deterministic signatures:
|
||||
|
||||
```text
|
||||
podId:eventType:normalizedFile:symbol:sortedEngineers
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Sort engineer names.
|
||||
- Normalize file paths.
|
||||
- Use `*` for missing symbol.
|
||||
- Never include timestamps.
|
||||
|
||||
## UI-Facing Loop Snapshot
|
||||
|
||||
The graph response may include:
|
||||
|
||||
```text
|
||||
loop
|
||||
activeStep
|
||||
steps[]
|
||||
key
|
||||
label
|
||||
value
|
||||
detail
|
||||
status
|
||||
```
|
||||
|
||||
Step mapping:
|
||||
|
||||
| Step | Source |
|
||||
| --- | --- |
|
||||
| Observe | recent observations and git updates |
|
||||
| Store | team model, graph records, memory vectors |
|
||||
| Predict | open collisions |
|
||||
| Outcome | accepted and dismissed outcomes |
|
||||
| Adapt | learned owners, learned edges, strategy changes |
|
||||
|
||||
## Activity Stream
|
||||
|
||||
The graph response may include:
|
||||
|
||||
```text
|
||||
activity[]
|
||||
id
|
||||
at
|
||||
kind
|
||||
title
|
||||
detail
|
||||
nodeId
|
||||
edgeId
|
||||
```
|
||||
|
||||
Allowed `kind` values:
|
||||
|
||||
```text
|
||||
editing, collision, intervention, outcome, learned, agent
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- The system can show one accepted outcome changing future memory.
|
||||
- Exact recall works without vector search.
|
||||
- The Team memory graph can explain the learning loop.
|
||||
- Dismissals and false positives are retained.
|
||||
- The demo does not rely on raw screenshots or hidden state.
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Graph Discovery Plan
|
||||
|
||||
Status: draft
|
||||
Goal: make MongoDB graph discovery visible as a dynamic learning observatory
|
||||
|
||||
## Must-Have
|
||||
|
||||
1. Keep live materializer as source of graph truth.
|
||||
2. Add optional loop and activity fields.
|
||||
3. Build a dynamic graph layout.
|
||||
4. Default to risk path.
|
||||
5. Make selected-node detail explain the story.
|
||||
|
||||
## Build Order
|
||||
|
||||
### R1: Stabilize discovered graph
|
||||
|
||||
- Keep file and engineer noise filters.
|
||||
- Keep collision collapse.
|
||||
- Keep priority for accepted-outcome paths.
|
||||
- Keep graph size capped.
|
||||
|
||||
### R2: Add observatory data
|
||||
|
||||
- Compute learning-loop snapshot.
|
||||
- Compute activity stream.
|
||||
- Preserve current graph contract.
|
||||
|
||||
### R3: Improve path selection
|
||||
|
||||
- Pick one primary risk path.
|
||||
- Include learned path when present.
|
||||
- Dim unrelated collisions and repeated interventions.
|
||||
|
||||
### R4: Render dynamically
|
||||
|
||||
- Use `d3-force` or animated layered layout.
|
||||
- Make nodes draggable.
|
||||
- Curve or bundle edges.
|
||||
- Animate `learned_from`.
|
||||
|
||||
### R5: Verify with real data
|
||||
|
||||
- Fetch live `demo-pod` graph.
|
||||
- Confirm labels do not collide badly.
|
||||
- Confirm red edges do not dominate.
|
||||
- Confirm activity and loop explain the graph.
|
||||
|
||||
## Nice-to-Have
|
||||
|
||||
- Reachability panel using `$graphLookup`.
|
||||
- Hover path previews.
|
||||
- Edge bundling by file or collision.
|
||||
- Time scrubber for graph snapshots.
|
||||
|
||||
## Cut
|
||||
|
||||
- Generic analytics dashboard.
|
||||
- Large graph database migration.
|
||||
- Rendering every historical event.
|
||||
- Static fixed-column final layout.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Risk path is obvious in 10 seconds.
|
||||
- Learned path is visible when data exists.
|
||||
- Whole graph mode exists but is not the default.
|
||||
- The graph remains backed by MongoDB, not hardcoded mock data.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Graph Discovery Policy
|
||||
|
||||
Status: draft
|
||||
Scope: graph hygiene, evidence thresholds, and UI truthfulness
|
||||
|
||||
## Prime Rule
|
||||
|
||||
The graph must be sparse enough to explain the learning loop and truthful enough
|
||||
to audit from MongoDB.
|
||||
|
||||
## Node Policy
|
||||
|
||||
Create nodes only when they add explanation value.
|
||||
|
||||
Allowed:
|
||||
|
||||
- Current engineers.
|
||||
- Real files.
|
||||
- Current or recent collisions.
|
||||
- Interventions tied to surviving collisions.
|
||||
- Learned ownership paths.
|
||||
|
||||
Avoid:
|
||||
|
||||
- Test engineers.
|
||||
- Scratch files.
|
||||
- URLs or environment values misread as files.
|
||||
- Repeated identical intervention diamonds.
|
||||
- Orphan nodes with no story value.
|
||||
|
||||
## Edge Policy
|
||||
|
||||
Edges need evidence.
|
||||
|
||||
| Edge | Required evidence |
|
||||
| --- | --- |
|
||||
| `editing` | observation or git state |
|
||||
| `touches` | file involved in collision |
|
||||
| `collides` | collision prediction |
|
||||
| `warns` | intervention record |
|
||||
| `learned_from` | accepted real outcome |
|
||||
| `owns` | learned or configured ownership |
|
||||
|
||||
## De-Hairball Policy
|
||||
|
||||
Default mode must not show every relationship equally.
|
||||
|
||||
Rules:
|
||||
|
||||
- Default to risk path.
|
||||
- Collapse repeated collision signatures.
|
||||
- Cap files and collisions.
|
||||
- Dim non-risk edges.
|
||||
- Bundle or curve dense edges.
|
||||
- Hide low-priority labels until hover or select.
|
||||
- Prefer selected-node explanation over labels everywhere.
|
||||
|
||||
## Truthfulness Policy
|
||||
|
||||
- Do not show `learned_from` for orphaned or dismissed outcomes.
|
||||
- Do not label vector similarity as learned memory.
|
||||
- Do not show demo seed as live learning unless labeled.
|
||||
- Do not hide false positives from activity or memory.
|
||||
|
||||
## Privacy Policy
|
||||
|
||||
Graph labels should not expose secrets, raw terminal output, or sensitive file
|
||||
contents. File paths are acceptable when they are repo paths and not secret
|
||||
values.
|
||||
|
||||
## Visual Policy
|
||||
|
||||
Semantic colors stay stable:
|
||||
|
||||
- Engineer: blue.
|
||||
- File: slate.
|
||||
- Feature: amber.
|
||||
- Collision: red.
|
||||
- Intervention: violet.
|
||||
- Learned: violet dashed edge.
|
||||
|
||||
Chrome should use the app's light shadcn tokens.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Graph Discovery Prompt
|
||||
|
||||
Use this prompt for an agent that materializes or reviews PodMan's Team memory
|
||||
graph.
|
||||
|
||||
## Prompt
|
||||
|
||||
You are PodMan's graph discovery agent.
|
||||
|
||||
Your job is to turn MongoDB records into a sparse, truthful graph that explains
|
||||
the continual-learning loop. Do not maximize node count. Maximize legibility and
|
||||
evidence.
|
||||
|
||||
The default output should show the risk path and learned path, not every
|
||||
possible edge.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Pod id.
|
||||
- Pod roster.
|
||||
- Recent engineer states.
|
||||
- Recent observations.
|
||||
- Collisions.
|
||||
- Interventions.
|
||||
- Outcomes.
|
||||
- Team model.
|
||||
- Existing graph nodes and edges.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. Normalize file paths.
|
||||
2. Remove noise.
|
||||
3. Create engineer and file nodes.
|
||||
4. Collapse repeated collisions by signature.
|
||||
5. Preserve accepted-outcome paths.
|
||||
6. Create intervention nodes for surviving collisions.
|
||||
7. Create learned edges only from accepted real outcomes.
|
||||
8. Select the primary risk path.
|
||||
9. Build activity and loop summaries.
|
||||
10. Explain selected-node stories.
|
||||
|
||||
## Output Format
|
||||
|
||||
```text
|
||||
Graph Summary
|
||||
- Pod:
|
||||
- Nodes:
|
||||
- Edges:
|
||||
- Primary risk path:
|
||||
- Learned path:
|
||||
|
||||
Discovery Decisions
|
||||
- Collapsed:
|
||||
- Dropped as noise:
|
||||
- Preserved because learned:
|
||||
|
||||
Loop
|
||||
- Observe:
|
||||
- Store:
|
||||
- Predict:
|
||||
- Outcome:
|
||||
- Adapt:
|
||||
|
||||
Activity
|
||||
- Recent events:
|
||||
|
||||
Risks
|
||||
- Missing evidence:
|
||||
- Potential hairball:
|
||||
- Demo caveat:
|
||||
```
|
||||
|
||||
## Hard Rules
|
||||
|
||||
- No `learned_from` without accepted real outcome.
|
||||
- No raw screenshots or secrets in labels.
|
||||
- Do not rewrite the backend materializer unless explicitly asked.
|
||||
- Prefer additive graph fields.
|
||||
- Default to risk path.
|
||||
- Keep whole graph optional.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Graph Discovery Spec
|
||||
|
||||
Status: draft
|
||||
Scope: how PodMan discovers graph nodes, edges, risk paths, and learning paths from MongoDB
|
||||
Owner: graph discovery / Team memory observatory
|
||||
|
||||
## Purpose
|
||||
|
||||
Graph discovery turns MongoDB memory into a legible Team memory graph. It is not
|
||||
only layout. It decides which relationships matter, which path is highlighted,
|
||||
and which evidence explains the graph.
|
||||
|
||||
The graph must answer:
|
||||
|
||||
1. Who is working?
|
||||
2. Which files or symbols overlap?
|
||||
3. Where is the risk?
|
||||
4. What did PodMan do?
|
||||
5. What outcome changed memory?
|
||||
|
||||
## Source Data
|
||||
|
||||
Graph discovery reads:
|
||||
|
||||
- `pods`
|
||||
- `engineer_states`
|
||||
- `observations`
|
||||
- `collisions`
|
||||
- `interventions`
|
||||
- `outcomes`
|
||||
- `team_model`
|
||||
- `graph_nodes`
|
||||
- `graph_edges`
|
||||
- optional `memory_vectors`
|
||||
- optional `agent_runs`
|
||||
- optional `strategy_versions`
|
||||
|
||||
## UI Graph Contract
|
||||
|
||||
```text
|
||||
PodGraph
|
||||
podId
|
||||
generatedAt
|
||||
nodes
|
||||
edges
|
||||
metrics
|
||||
loop?
|
||||
activity?
|
||||
```
|
||||
|
||||
Node kinds:
|
||||
|
||||
```text
|
||||
engineer, feature, file, collision, intervention
|
||||
```
|
||||
|
||||
Edge kinds:
|
||||
|
||||
```text
|
||||
owns, editing, touches, collides, warns, learned_from
|
||||
```
|
||||
|
||||
## Discovery Rules
|
||||
|
||||
### Engineer nodes
|
||||
|
||||
Create from pod roster, recent observations, git state, or collision membership.
|
||||
|
||||
### File nodes
|
||||
|
||||
Create only from normalized real file paths. Reject noise such as URLs, env
|
||||
values, scratch names, and non-file strings.
|
||||
|
||||
### Collision nodes
|
||||
|
||||
Create from distinct collision signatures. Collapse repeats. Prioritize
|
||||
collisions referenced by accepted outcomes.
|
||||
|
||||
### Intervention nodes
|
||||
|
||||
Create one visible intervention per surviving collision unless whole-graph mode
|
||||
explicitly expands history.
|
||||
|
||||
### Learned paths
|
||||
|
||||
Create `learned_from` only when an accepted real outcome links an intervention
|
||||
to a durable memory update.
|
||||
|
||||
## Path Modes
|
||||
|
||||
### Risk path
|
||||
|
||||
Default mode. Highlight the clearest current chain:
|
||||
|
||||
```text
|
||||
engineer -> file -> collision -> intervention -> learned owner
|
||||
```
|
||||
|
||||
Dim unrelated graph material.
|
||||
|
||||
### Learning edges
|
||||
|
||||
Highlight `learned_from`, `owns`, and the outcomes that produced them.
|
||||
|
||||
### Whole graph
|
||||
|
||||
Show all materialized nodes and edges with de-emphasized non-critical edges.
|
||||
|
||||
## MongoDB Traversal
|
||||
|
||||
Use `graph_edges` for reachability:
|
||||
|
||||
```text
|
||||
source -> target -> next target
|
||||
```
|
||||
|
||||
Primary traversal questions:
|
||||
|
||||
- What risks does this engineer reach?
|
||||
- Which files feed this collision?
|
||||
- Which intervention came from this collision?
|
||||
- Which learned owner came from this intervention?
|
||||
|
||||
## Metrics
|
||||
|
||||
Minimum metrics:
|
||||
|
||||
- Learned owners.
|
||||
- Open risk paths.
|
||||
- Accept rate.
|
||||
|
||||
Optional metrics:
|
||||
|
||||
- Observations.
|
||||
- Interventions.
|
||||
- Memory vectors.
|
||||
- Strategy versions.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Default graph is not a hairball.
|
||||
- Every visible learned edge has outcome evidence.
|
||||
- Every selected node can explain why it matters.
|
||||
- Activity stream matches graph events.
|
||||
- Graph can be rebuilt from MongoDB source records.
|
||||
|
||||
@@ -1,111 +1,36 @@
|
||||
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import type { PodGraph, PodGraphNode, PodGraphEdge, PodGraphNodeKind } from '@podman/shared';
|
||||
import { fetchPodGraph } from '../lib/graph.js';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { PodGraph } from '@podman/shared';
|
||||
import { fetchPodGraph, backendEventsUrl } from '../lib/graph.js';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { GraphCanvas } from './graph/GraphCanvas.js';
|
||||
import { MetricsRail } from './graph/MetricsRail.js';
|
||||
import { LearningLoop } from './graph/LearningLoop.js';
|
||||
import { ActivityStream } from './graph/ActivityStream.js';
|
||||
import { SelectedNodePanel } from './graph/SelectedNodePanel.js';
|
||||
import { highlightFor, flowNarrative, NODE_LEGEND, EDGE_LEGEND, type Mode } from './graph/encoding.js';
|
||||
|
||||
type Mode = 'risk' | 'learn' | 'all';
|
||||
const POLL_MS = 5000;
|
||||
|
||||
// Fixed, light-readable hues for the node/edge encoding (kept stable across
|
||||
// light/dark so kinds stay distinguishable; the chrome uses shadcn tokens).
|
||||
const BLUE = '#2563eb';
|
||||
const SLATE = '#475569';
|
||||
const SLATE_EDGE = '#94a3b8';
|
||||
const SLATE_FAINT = '#cbd5e1';
|
||||
const AMBER = '#d97706';
|
||||
const RED = '#dc2626';
|
||||
const VIOLET = '#7c3aed';
|
||||
|
||||
const KIND_COLOR: Record<PodGraphNodeKind, string> = {
|
||||
engineer: BLUE,
|
||||
file: SLATE,
|
||||
feature: AMBER,
|
||||
collision: RED,
|
||||
intervention: VIOLET,
|
||||
};
|
||||
|
||||
const EDGE: Record<PodGraphEdge['kind'], { c: string; w: number; dash?: boolean }> = {
|
||||
owns: { c: BLUE, w: 2.6 },
|
||||
editing: { c: SLATE_EDGE, w: 2 },
|
||||
touches: { c: SLATE_FAINT, w: 1.6 },
|
||||
collides: { c: RED, w: 3.2 },
|
||||
warns: { c: AMBER, w: 3.2 },
|
||||
learned_from: { c: VIOLET, w: 2.4, dash: true },
|
||||
};
|
||||
|
||||
function NodeShape({ node }: { node: PodGraphNode }) {
|
||||
const c = KIND_COLOR[node.kind];
|
||||
const { x, y } = node;
|
||||
switch (node.kind) {
|
||||
case 'engineer':
|
||||
return <rect x={x - 15} y={y - 15} width={30} height={30} rx={4} fill={c} />;
|
||||
case 'file':
|
||||
return (
|
||||
<rect
|
||||
x={x - 15}
|
||||
y={y - 15}
|
||||
width={30}
|
||||
height={30}
|
||||
rx={4}
|
||||
fill="none"
|
||||
stroke={c}
|
||||
strokeWidth={2.6}
|
||||
/>
|
||||
);
|
||||
case 'feature':
|
||||
return <circle cx={x} cy={y} r={17} fill={c} />;
|
||||
case 'collision':
|
||||
return <polygon points={`${x},${y - 18} ${x + 17},${y + 13} ${x - 17},${y + 13}`} fill={c} />;
|
||||
case 'intervention':
|
||||
return (
|
||||
<polygon points={`${x},${y - 18} ${x + 18},${y} ${x},${y + 18} ${x - 18},${y}`} fill={c} />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
// Note: pm-enter must NOT use animation-fill-mode (both/forwards) — a held final
|
||||
// keyframe (opacity:1) would override the .pm-dim cascade and defeat dimming.
|
||||
const GRAPH_CSS = `
|
||||
.pm-node{cursor:grab;transition:opacity .25s ease}
|
||||
.pm-node:active{cursor:grabbing}
|
||||
.pm-edge{transition:opacity .25s ease}
|
||||
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500;pointer-events:none;
|
||||
paint-order:stroke;stroke:var(--card);stroke-width:3.5px;stroke-linejoin:round}
|
||||
.pm-dim{opacity:.14}
|
||||
.pm-enter{animation:pm-fade .45s ease}
|
||||
.pm-dash{animation:pm-flow 1s linear infinite}
|
||||
.pm-pulse{animation:pm-pulse 1.7s ease-in-out infinite}
|
||||
@keyframes pm-fade{from{opacity:0}to{opacity:1}}
|
||||
@keyframes pm-flow{to{stroke-dashoffset:-26}}
|
||||
@keyframes pm-pulse{0%,100%{opacity:.45}50%{opacity:1}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.pm-enter,.pm-dash,.pm-pulse{animation:none}
|
||||
}
|
||||
}
|
||||
|
||||
interface Highlight {
|
||||
nodes: Set<string>;
|
||||
edges: Set<string>;
|
||||
}
|
||||
|
||||
function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Highlight | null {
|
||||
if (selected) {
|
||||
const es = graph.edges.filter((e) => e.source === selected || e.target === selected);
|
||||
return {
|
||||
nodes: new Set([selected, ...es.flatMap((e) => [e.source, e.target])]),
|
||||
edges: new Set(es.map((e) => e.id)),
|
||||
};
|
||||
}
|
||||
if (mode === 'all') return null;
|
||||
const kinds: PodGraphEdge['kind'][] =
|
||||
mode === 'risk' ? ['collides', 'warns', 'learned_from'] : ['learned_from', 'warns'];
|
||||
const collisions = new Set(graph.nodes.filter((n) => n.kind === 'collision').map((n) => n.id));
|
||||
const es = graph.edges.filter(
|
||||
(e) =>
|
||||
kinds.includes(e.kind) ||
|
||||
(mode === 'risk' && (collisions.has(e.target) || collisions.has(e.source))),
|
||||
);
|
||||
return {
|
||||
nodes: new Set(es.flatMap((e) => [e.source, e.target])),
|
||||
edges: new Set(es.map((e) => e.id)),
|
||||
};
|
||||
}
|
||||
|
||||
const LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
|
||||
{ label: 'engineer', swatch: { background: BLUE } },
|
||||
{ label: 'file', swatch: { border: `2px solid ${SLATE}` } },
|
||||
{ label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } },
|
||||
{ label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } },
|
||||
{ label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } },
|
||||
];
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === 'risk') return RED;
|
||||
if (status === 'learned') return VIOLET;
|
||||
return 'var(--foreground)';
|
||||
}
|
||||
`;
|
||||
|
||||
export function GraphView({ podId, onClose }: { podId: string; onClose: () => void }) {
|
||||
const [graph, setGraph] = useState<PodGraph | null>(null);
|
||||
@@ -115,198 +40,174 @@ export function GraphView({ podId, onClose }: { podId: string; onClose: () => vo
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
let nudge: number | null = null;
|
||||
setGraph(null);
|
||||
setError(null);
|
||||
setSelected(null);
|
||||
|
||||
const load = () =>
|
||||
fetchPodGraph(podId)
|
||||
.then((g) => alive && setGraph(g))
|
||||
.catch((e: unknown) => alive && setError(e instanceof Error ? e.message : String(e)));
|
||||
.then((g) => {
|
||||
if (alive) {
|
||||
setGraph(g);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (alive) setError(e instanceof Error ? e.message : String(e));
|
||||
});
|
||||
|
||||
void load();
|
||||
const poll = window.setInterval(() => void load(), POLL_MS);
|
||||
|
||||
// Best-effort realtime nudge: refetch (debounced) when the agent broadcasts.
|
||||
let ws: WebSocket | null = null;
|
||||
try {
|
||||
ws = new WebSocket(backendEventsUrl());
|
||||
ws.onmessage = () => {
|
||||
if (nudge != null) return;
|
||||
nudge = window.setTimeout(() => {
|
||||
nudge = null;
|
||||
void load();
|
||||
}, 800);
|
||||
};
|
||||
} catch {
|
||||
/* event bus is optional */
|
||||
}
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(poll);
|
||||
if (nudge != null) window.clearTimeout(nudge);
|
||||
ws?.close();
|
||||
};
|
||||
}, [podId]);
|
||||
|
||||
const hi = useMemo(
|
||||
() => (graph ? highlightFor(graph, mode, selected) : null),
|
||||
[graph, mode, selected],
|
||||
);
|
||||
const nodeById = useMemo(() => new Map((graph?.nodes ?? []).map((n) => [n.id, n])), [graph]);
|
||||
const sel = selected ? nodeById.get(selected) : undefined;
|
||||
const relCount = selected
|
||||
? (graph?.edges ?? []).filter((e) => e.source === selected || e.target === selected).length
|
||||
: 0;
|
||||
// A selected node can vanish across a poll/WS refresh. Ignore a stale id so the
|
||||
// graph doesn't dim entirely (highlightFor would otherwise light only a dead id).
|
||||
const liveSelected = selected && nodeById.has(selected) ? selected : null;
|
||||
useEffect(() => {
|
||||
if (selected && graph && !nodeById.has(selected)) setSelected(null);
|
||||
}, [graph, nodeById, selected]);
|
||||
|
||||
const dimNode = (id: string) => (hi ? !hi.nodes.has(id) : false);
|
||||
const dimEdge = (id: string) => (hi ? !hi.edges.has(id) : false);
|
||||
const hotEdge = (id: string) => (hi ? hi.edges.has(id) : false);
|
||||
const highlight = useMemo(
|
||||
() => (graph ? highlightFor(graph, mode, liveSelected) : null),
|
||||
[graph, mode, liveSelected],
|
||||
);
|
||||
const sel = liveSelected ? nodeById.get(liveSelected) : undefined;
|
||||
const relCount = liveSelected
|
||||
? (graph?.edges ?? []).filter((e) => e.source === liveSelected || e.target === liveSelected)
|
||||
.length
|
||||
: 0;
|
||||
const flow = graph && liveSelected ? flowNarrative(graph, liveSelected) : '';
|
||||
|
||||
function pick(next: Mode) {
|
||||
setMode(next);
|
||||
setSelected(null);
|
||||
}
|
||||
|
||||
const toggleVariant = (m: Mode) => (mode === m && !selected ? 'default' : 'outline');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<style>{GRAPH_CSS}</style>
|
||||
<div className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<style>{`
|
||||
.pm-node{cursor:pointer}
|
||||
.pm-lbl{fill:var(--foreground);font-size:11px;font-weight:500}
|
||||
.pm-dim{opacity:.18;transition:opacity .25s}
|
||||
`}</style>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground">
|
||||
<div className="flex items-center justify-between border-b px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-base font-medium">Team memory</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">What PodMan learned · {podId}</p>
|
||||
<div className="overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3 border-b px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-heading text-base font-medium">Team memory</h2>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span className="pm-pulse inline-block size-1.5 rounded-full bg-[#16a34a]" />
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
What PodMan learned · {podId}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
← Pods
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-b px-4 py-3">
|
||||
<Button variant={toggleVariant('risk')} size="sm" onClick={() => pick('risk')}>
|
||||
Risk path
|
||||
</Button>
|
||||
<Button variant={toggleVariant('learn')} size="sm" onClick={() => pick('learn')}>
|
||||
Learning edges
|
||||
</Button>
|
||||
<Button variant={toggleVariant('all')} size="sm" onClick={() => pick('all')}>
|
||||
Whole graph
|
||||
</Button>
|
||||
{/* Toggles */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-3">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={mode}
|
||||
onValueChange={(v) => v && pick(v as Mode)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem value="risk">Risk path</ToggleGroupItem>
|
||||
<ToggleGroupItem value="learn">Learning edges</ToggleGroupItem>
|
||||
<ToggleGroupItem value="all">Whole graph</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<span className="ml-auto hidden text-xs text-muted-foreground sm:inline">
|
||||
Drag to rearrange · double-click to release · click to inspect
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="px-4 py-4 text-sm text-destructive">Graph error: {error}</p>}
|
||||
{!graph && !error && (
|
||||
<p className="px-4 py-4 text-sm text-muted-foreground">Loading graph…</p>
|
||||
<p className="px-4 py-10 text-center text-sm text-muted-foreground">Loading graph…</p>
|
||||
)}
|
||||
|
||||
{graph && (
|
||||
<>
|
||||
<div className="grid lg:grid-cols-[190px_1fr_250px]">
|
||||
<div className="space-y-3 p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Workflow metrics
|
||||
</p>
|
||||
{graph.metrics.map((m) => (
|
||||
<div key={m.label} className="rounded-lg border bg-card px-3 py-2.5">
|
||||
<p className="text-2xl font-medium tabular-nums">{m.value}</p>
|
||||
<p className="mt-1 text-xs font-medium uppercase text-muted-foreground">
|
||||
{m.label}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-snug text-muted-foreground">{m.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Metrics · graph · learning loop */}
|
||||
<div className="grid gap-4 p-4 lg:grid-cols-[180px_minmax(0,1fr)_212px]">
|
||||
<MetricsRail metrics={graph.metrics} />
|
||||
|
||||
<div className="min-h-[472px] border-y bg-card lg:border-x lg:border-y-0">
|
||||
<svg
|
||||
viewBox="0 0 720 472"
|
||||
role="img"
|
||||
aria-label="PodMan team-memory graph"
|
||||
className="block h-auto w-full"
|
||||
>
|
||||
{graph.edges.map((e) => {
|
||||
const a = nodeById.get(e.source);
|
||||
const b = nodeById.get(e.target);
|
||||
if (!a || !b) return null;
|
||||
const s = EDGE[e.kind];
|
||||
return (
|
||||
<line
|
||||
key={e.id}
|
||||
className={dimEdge(e.id) ? 'pm-dim' : undefined}
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
stroke={s.c}
|
||||
strokeWidth={hotEdge(e.id) ? s.w + 1.6 : s.w}
|
||||
strokeDasharray={s.dash ? '7 6' : undefined}
|
||||
strokeLinecap="round"
|
||||
<div className="flex min-h-[440px] flex-col overflow-hidden rounded-xl border bg-card">
|
||||
<GraphCanvas
|
||||
graph={graph}
|
||||
highlight={highlight}
|
||||
selected={liveSelected}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{graph.nodes.map((n) => (
|
||||
<g
|
||||
key={n.id}
|
||||
className={`pm-node ${dimNode(n.id) ? 'pm-dim' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${n.kind}: ${n.label}`}
|
||||
onClick={() => setSelected((cur) => (cur === n.id ? null : n.id))}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
setSelected((cur) => (cur === n.id ? null : n.id));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<NodeShape node={n} />
|
||||
<text className="pm-lbl" x={n.x} y={n.y + 33} textAnchor="middle">
|
||||
{n.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="border-t bg-muted p-4 lg:border-l lg:border-t-0">
|
||||
{sel ? (
|
||||
<>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{sel.kind}
|
||||
</p>
|
||||
<h3 className="mb-3 mt-1 text-lg font-medium">{sel.label}</h3>
|
||||
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
|
||||
<span>Status</span>
|
||||
<Badge variant="outline" style={{ color: statusColor(sel.status) }}>
|
||||
{sel.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
|
||||
<span>Relationships</span>
|
||||
<span className="font-medium text-foreground">{relCount}</span>
|
||||
</div>
|
||||
<p className="mt-2.5 text-sm leading-relaxed text-muted-foreground">
|
||||
{sel.summary}
|
||||
</p>
|
||||
</>
|
||||
{graph.loop?.length ? (
|
||||
<LearningLoop stages={graph.loop} />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Continual learning
|
||||
</p>
|
||||
<h3 className="mb-3 mt-1 text-lg font-medium">It learned</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
The violet{' '}
|
||||
<span className="font-medium" style={{ color: VIOLET }}>
|
||||
learned_from
|
||||
</span>{' '}
|
||||
edges are ownership PodMan retained from accepted interventions — the graph
|
||||
gets sharper every session. Click any node to trace its relationships.
|
||||
</p>
|
||||
</>
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Activity stream · selected node */}
|
||||
<div className="grid gap-4 border-t px-4 py-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<ActivityStream events={graph.activity ?? []} />
|
||||
</div>
|
||||
<div className="rounded-xl border bg-muted/40 p-4">
|
||||
<SelectedNodePanel node={sel} relCount={relCount} flow={flow} mode={mode} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 border-t px-4 py-2.5 text-xs text-muted-foreground">
|
||||
{LEGEND.map((l) => (
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-t px-4 py-2.5 text-xs text-muted-foreground">
|
||||
{NODE_LEGEND.map((l) => (
|
||||
<span key={l.label} className="flex items-center gap-1.5">
|
||||
<span className="inline-block size-3" style={l.swatch} />
|
||||
{l.label}
|
||||
</span>
|
||||
))}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-[3px] w-3" style={{ background: RED }} />
|
||||
collides
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-[3px] w-3" style={{ background: VIOLET }} />
|
||||
learned_from
|
||||
<span className="mx-1 h-3 w-px bg-border" aria-hidden />
|
||||
{EDGE_LEGEND.map((l) => (
|
||||
<span key={l.label} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-[3px] w-3.5"
|
||||
style={
|
||||
l.dash
|
||||
? { backgroundImage: `repeating-linear-gradient(90deg, ${l.color} 0 3px, transparent 3px 6px)` }
|
||||
: { background: l.color }
|
||||
}
|
||||
/>
|
||||
{l.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ActivityEvent } from '@podman/shared';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { ACTIVITY_TAG } from './encoding.js';
|
||||
|
||||
const fmtTime = new Intl.DateTimeFormat([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
|
||||
function timeOf(at: string): string {
|
||||
const t = new Date(at).getTime();
|
||||
return Number.isFinite(t) ? fmtTime.format(t) : '--:--';
|
||||
}
|
||||
|
||||
export function ActivityStream({ events }: { events: ActivityEvent[] }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Activity stream
|
||||
</p>
|
||||
{events.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No activity yet.</p>
|
||||
) : (
|
||||
<ScrollArea className="h-[176px] pr-3">
|
||||
<ul className="space-y-1.5">
|
||||
{events.map((e) => {
|
||||
const tag = ACTIVITY_TAG[e.kind];
|
||||
return (
|
||||
<li key={e.id} className="pm-enter flex items-start gap-2.5 text-sm">
|
||||
<span className="mt-0.5 shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{timeOf(e.at)}
|
||||
</span>
|
||||
<span
|
||||
className="mt-0.5 shrink-0 rounded px-1.5 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide"
|
||||
style={{ color: tag.color, background: `${tag.color}1a` }}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 leading-snug text-foreground/90">{e.text}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type PointerEvent,
|
||||
} from 'react';
|
||||
import type { PodGraph, PodGraphNode, PodGraphNodeKind } from '@podman/shared';
|
||||
import { ForceSim } from './forceSim.js';
|
||||
import { EDGE, KIND_COLOR, nodeRadius, type Highlight } from './encoding.js';
|
||||
|
||||
const W = 760;
|
||||
const H = 480;
|
||||
const MARGIN = 48;
|
||||
|
||||
/** Map the server's 0..720×0..472 layout into the canvas as a seed position. */
|
||||
function mapX(x: number): number {
|
||||
return MARGIN + (Math.max(0, Math.min(720, x)) / 720) * (W - 2 * MARGIN);
|
||||
}
|
||||
function mapY(y: number): number {
|
||||
return MARGIN + (Math.max(0, Math.min(472, y)) / 472) * (H - 2 * MARGIN);
|
||||
}
|
||||
|
||||
function linkDistance(kind: string): number {
|
||||
if (kind === 'collides') return 122;
|
||||
if (kind === 'owns') return 104;
|
||||
if (kind === 'learned_from') return 150;
|
||||
return 134;
|
||||
}
|
||||
function linkStrength(strength: number): number {
|
||||
return Math.max(0.18, Math.min(0.9, strength));
|
||||
}
|
||||
|
||||
/** Stable +/- so parallel edges between the same pair fan to opposite sides. */
|
||||
function curveSign(id: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < id.length; i++) h = (h + id.charCodeAt(i)) % 2;
|
||||
return h === 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
function edgePath(ax: number, ay: number, bx: number, by: number, id: string): string {
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const nx = -dy / len;
|
||||
const ny = dx / len;
|
||||
const off = curveSign(id) * len * 0.13;
|
||||
const cx = (ax + bx) / 2 + nx * off;
|
||||
const cy = (ay + by) / 2 + ny * off;
|
||||
return `M${ax.toFixed(1)},${ay.toFixed(1)} Q${cx.toFixed(1)},${cy.toFixed(1)} ${bx.toFixed(1)},${by.toFixed(1)}`;
|
||||
}
|
||||
|
||||
function nodeShape(
|
||||
kind: PodGraphNodeKind,
|
||||
color: string,
|
||||
cx: number,
|
||||
cy: number,
|
||||
r: number,
|
||||
): ReactElement | null {
|
||||
switch (kind) {
|
||||
case 'engineer':
|
||||
return <rect x={cx - r} y={cy - r} width={r * 2} height={r * 2} rx={4} fill={color} />;
|
||||
case 'file':
|
||||
return (
|
||||
<rect
|
||||
x={cx - r}
|
||||
y={cy - r}
|
||||
width={r * 2}
|
||||
height={r * 2}
|
||||
rx={4}
|
||||
fill="var(--card)"
|
||||
stroke={color}
|
||||
strokeWidth={2.4}
|
||||
/>
|
||||
);
|
||||
case 'feature':
|
||||
return <circle cx={cx} cy={cy} r={r} fill={color} />;
|
||||
case 'collision':
|
||||
return (
|
||||
<polygon
|
||||
points={`${cx},${cy - r} ${cx + r},${cy + r * 0.78} ${cx - r},${cy + r * 0.78}`}
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
case 'intervention':
|
||||
return (
|
||||
<polygon points={`${cx},${cy - r} ${cx + r},${cy} ${cx},${cy + r} ${cx - r},${cy}`} fill={color} />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function showLabel(
|
||||
node: PodGraphNode,
|
||||
dimmed: boolean,
|
||||
hovered: boolean,
|
||||
selected: boolean,
|
||||
): boolean {
|
||||
if (hovered || selected) return true;
|
||||
if (dimmed) return false;
|
||||
// Collisions cluster and often share a filename — reveal on hover/select only.
|
||||
if (node.kind === 'collision') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
id: string;
|
||||
pointerId: number;
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
export function GraphCanvas({
|
||||
graph,
|
||||
highlight,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
graph: PodGraph;
|
||||
highlight: Highlight | null;
|
||||
selected: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
}) {
|
||||
const svgRef = useRef<SVGSVGElement | null>(null);
|
||||
const simRef = useRef<ForceSim | null>(null);
|
||||
if (!simRef.current) simRef.current = new ForceSim(W, H);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const sigRef = useRef<string>('');
|
||||
const [, setFrame] = useState(0);
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
|
||||
const loop = useCallback(() => {
|
||||
const sim = simRef.current;
|
||||
if (!sim) return;
|
||||
const working = sim.tick();
|
||||
setFrame((f) => (f + 1) % 1_000_000);
|
||||
if (working || dragRef.current) {
|
||||
rafRef.current = requestAnimationFrame(loop);
|
||||
} else {
|
||||
rafRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const ensureRaf = useCallback(() => {
|
||||
if (rafRef.current == null) rafRef.current = requestAnimationFrame(loop);
|
||||
}, [loop]);
|
||||
|
||||
// Rebuild the simulation when the graph data changes, preserving positions.
|
||||
useEffect(() => {
|
||||
const sim = simRef.current;
|
||||
if (!sim) return;
|
||||
const nodeInputs = graph.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
radius: nodeRadius(n),
|
||||
seedX: mapX(n.x),
|
||||
seedY: mapY(n.y),
|
||||
}));
|
||||
const linkInputs = graph.edges.map((e) => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
distance: linkDistance(e.kind),
|
||||
strength: linkStrength(e.strength),
|
||||
}));
|
||||
const sig =
|
||||
nodeInputs
|
||||
.map((n) => n.id)
|
||||
.sort()
|
||||
.join(',') +
|
||||
'|' +
|
||||
graph.edges
|
||||
.map((e) => e.id)
|
||||
.sort()
|
||||
.join(',');
|
||||
const first = sigRef.current === '';
|
||||
const changed = sig !== sigRef.current;
|
||||
sim.setData(nodeInputs, linkInputs);
|
||||
if (changed) {
|
||||
sigRef.current = sig;
|
||||
sim.reheat(first ? 1 : 0.5);
|
||||
}
|
||||
// Always (re)arm the loop — ensureRaf is idempotent via the rafRef==null
|
||||
// guard. This must NOT be gated on `changed`: under React StrictMode the
|
||||
// dev double-invoke cancels the frame between effect passes, and pass 2 sees
|
||||
// an unchanged sig, so a `changed`-gated start would leave the sim frozen.
|
||||
if (sim.nodes.length) ensureRaf();
|
||||
}, [graph, ensureRaf]);
|
||||
|
||||
// Clean up the animation frame on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function toSvg(evt: PointerEvent): { x: number; y: number } {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return { x: 0, y: 0 };
|
||||
const ctm = svg.getScreenCTM();
|
||||
if (!ctm) return { x: 0, y: 0 };
|
||||
const p = new DOMPoint(evt.clientX, evt.clientY).matrixTransform(ctm.inverse());
|
||||
return { x: p.x, y: p.y };
|
||||
}
|
||||
|
||||
function onNodePointerDown(evt: PointerEvent, id: string) {
|
||||
evt.stopPropagation();
|
||||
const sim = simRef.current;
|
||||
if (!sim) return;
|
||||
(evt.currentTarget as Element).setPointerCapture(evt.pointerId);
|
||||
dragRef.current = { id, pointerId: evt.pointerId, moved: false };
|
||||
const { x, y } = toSvg(evt);
|
||||
sim.pin(id, x, y);
|
||||
sim.setActive(true);
|
||||
ensureRaf();
|
||||
}
|
||||
|
||||
function onNodePointerMove(evt: PointerEvent) {
|
||||
const drag = dragRef.current;
|
||||
const sim = simRef.current;
|
||||
if (!drag || !sim || drag.pointerId !== evt.pointerId) return;
|
||||
drag.moved = true;
|
||||
const { x, y } = toSvg(evt);
|
||||
sim.pin(drag.id, x, y);
|
||||
ensureRaf();
|
||||
}
|
||||
|
||||
function onNodePointerUp(evt: PointerEvent, id: string) {
|
||||
const drag = dragRef.current;
|
||||
const sim = simRef.current;
|
||||
if (!drag || !sim || drag.pointerId !== evt.pointerId) return;
|
||||
(evt.currentTarget as Element).releasePointerCapture?.(evt.pointerId);
|
||||
sim.setActive(false);
|
||||
// A press that never moved is a click — toggle selection (node stays pinned).
|
||||
if (!drag.moved) onSelect(selected === id ? null : id);
|
||||
dragRef.current = null;
|
||||
ensureRaf();
|
||||
}
|
||||
|
||||
function onNodeDoubleClick(id: string) {
|
||||
const sim = simRef.current;
|
||||
if (!sim) return;
|
||||
sim.unpin(id);
|
||||
sim.reheat(0.5);
|
||||
ensureRaf();
|
||||
}
|
||||
|
||||
const sim = simRef.current;
|
||||
const dimNode = (id: string) => (highlight ? !highlight.nodes.has(id) : false);
|
||||
const dimEdge = (id: string) => (highlight ? !highlight.edges.has(id) : false);
|
||||
const hotEdge = (id: string) => (highlight ? highlight.edges.has(id) : false);
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
role="img"
|
||||
aria-label="PodMan team-memory graph — drag nodes to rearrange"
|
||||
className="block h-full max-h-[560px] w-full touch-none select-none"
|
||||
onPointerDown={() => onSelect(null)}
|
||||
>
|
||||
<g>
|
||||
{graph.edges.map((e) => {
|
||||
const a = sim?.get(e.source);
|
||||
const b = sim?.get(e.target);
|
||||
if (!a || !b) return null;
|
||||
const style = EDGE[e.kind];
|
||||
const hot = hotEdge(e.id);
|
||||
return (
|
||||
<path
|
||||
key={e.id}
|
||||
className={`pm-edge pm-enter ${dimEdge(e.id) ? 'pm-dim' : ''} ${e.kind === 'learned_from' ? 'pm-dash' : ''}`}
|
||||
d={edgePath(a.x, a.y, b.x, b.y, e.id)}
|
||||
fill="none"
|
||||
stroke={style.c}
|
||||
strokeWidth={hot ? style.w + 1.4 : style.w}
|
||||
strokeOpacity={hot ? 1 : 0.78}
|
||||
strokeDasharray={style.dash ? '7 6' : undefined}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
<g>
|
||||
{graph.nodes.map((n) => {
|
||||
const p = sim?.get(n.id);
|
||||
if (!p) return null;
|
||||
const r = p.radius;
|
||||
const dimmed = dimNode(n.id);
|
||||
const isHover = hovered === n.id;
|
||||
const isSel = selected === n.id;
|
||||
const color = KIND_COLOR[n.kind];
|
||||
const pinned = p.fx != null;
|
||||
return (
|
||||
<g
|
||||
key={n.id}
|
||||
className={`pm-node pm-enter ${dimmed ? 'pm-dim' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${n.kind}: ${n.label}`}
|
||||
onPointerDown={(ev) => onNodePointerDown(ev, n.id)}
|
||||
onPointerMove={onNodePointerMove}
|
||||
onPointerUp={(ev) => onNodePointerUp(ev, n.id)}
|
||||
onDoubleClick={() => onNodeDoubleClick(n.id)}
|
||||
onMouseEnter={() => setHovered(n.id)}
|
||||
onMouseLeave={() => setHovered((cur) => (cur === n.id ? null : cur))}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
onSelect(selected === n.id ? null : n.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(isSel || isHover) && (
|
||||
<circle cx={p.x} cy={p.y} r={r + 7} fill="none" stroke={color} strokeWidth={2} strokeOpacity={0.5} />
|
||||
)}
|
||||
{pinned && !isSel && !isHover && (
|
||||
<circle cx={p.x} cy={p.y} r={r + 4} fill="none" stroke={color} strokeWidth={1} strokeDasharray="2 3" strokeOpacity={0.4} />
|
||||
)}
|
||||
{nodeShape(n.kind, color, p.x, p.y, r)}
|
||||
{showLabel(n, dimmed, isHover, isSel) && (
|
||||
<text className="pm-lbl" x={p.x} y={p.y + r + 13} textAnchor="middle">
|
||||
{n.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { LearningStage } from '@podman/shared';
|
||||
import { BLUE } from './encoding.js';
|
||||
|
||||
/**
|
||||
* The continual-learning loop rail: observe → store → predict → outcome → adapt.
|
||||
* The active stage (most-recent activity) gets a pulsing accent bar + ring.
|
||||
*/
|
||||
export function LearningLoop({ stages }: { stages: LearningStage[] }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Learning loop
|
||||
</p>
|
||||
{stages.map((s, i) => (
|
||||
<div key={s.key}>
|
||||
<div
|
||||
className="relative overflow-hidden rounded-lg border bg-card py-2 pl-3.5 pr-3 shadow-sm transition-colors data-[active=true]:bg-accent/40"
|
||||
data-active={s.active}
|
||||
style={s.active ? { boxShadow: `inset 0 0 0 1px ${BLUE}55` } : undefined}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`absolute inset-y-0 left-0 w-1 ${s.active ? 'pm-pulse' : ''}`}
|
||||
style={{ background: s.active ? BLUE : 'var(--border)' }}
|
||||
/>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span className="tabular-nums">{String(i + 1).padStart(2, '0')}</span> {s.title}
|
||||
</p>
|
||||
<p className="font-heading text-sm font-semibold tabular-nums">{s.value}</p>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs leading-snug text-muted-foreground">{s.detail}</p>
|
||||
</div>
|
||||
{i < stages.length - 1 && (
|
||||
<p aria-hidden className="py-0.5 text-center text-xs leading-none text-muted-foreground/60">
|
||||
↓
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { PodGraphMetric } from '@podman/shared';
|
||||
import { BLUE, RED, VIOLET, GREEN, AMBER } from './encoding.js';
|
||||
|
||||
const ACCENTS: Array<{ test: RegExp; color: string }> = [
|
||||
{ test: /risk|collision|open/i, color: RED },
|
||||
{ test: /accept/i, color: GREEN },
|
||||
{ test: /learn|owner|adapt/i, color: VIOLET },
|
||||
{ test: /vector|memory|store/i, color: AMBER },
|
||||
];
|
||||
|
||||
function accentFor(label: string, i: number): string {
|
||||
for (const a of ACCENTS) if (a.test.test(label)) return a.color;
|
||||
return [BLUE, RED, VIOLET, GREEN, AMBER][i % 5] ?? BLUE;
|
||||
}
|
||||
|
||||
export function MetricsRail({ metrics }: { metrics: PodGraphMetric[] }) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Workflow metrics
|
||||
</p>
|
||||
{metrics.map((m, i) => (
|
||||
<div
|
||||
key={m.label}
|
||||
className="rounded-lg border bg-card py-2.5 pl-3 pr-3 shadow-sm"
|
||||
style={{ borderLeftWidth: 3, borderLeftColor: accentFor(m.label, i) }}
|
||||
>
|
||||
<p className="font-heading text-2xl font-semibold leading-none tabular-nums">{m.value}</p>
|
||||
<p className="mt-1.5 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{m.label}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-snug text-muted-foreground">{m.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { PodGraphNode } from '@podman/shared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { statusColor, modeBlurb, VIOLET, type Mode } from './encoding.js';
|
||||
|
||||
export function SelectedNodePanel({
|
||||
node,
|
||||
relCount,
|
||||
flow,
|
||||
mode,
|
||||
}: {
|
||||
node: PodGraphNode | undefined;
|
||||
relCount: number;
|
||||
flow: string;
|
||||
mode: Mode;
|
||||
}) {
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{mode === 'learn' ? 'Learning edges' : mode === 'all' ? 'Whole graph' : 'Risk path'}
|
||||
</p>
|
||||
<h3 className="mb-2 font-heading text-base font-medium">What you're looking at</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{modeBlurb(mode)}</p>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
|
||||
Click any node to trace its{' '}
|
||||
<span className="font-medium" style={{ color: VIOLET }}>
|
||||
flow
|
||||
</span>{' '}
|
||||
— what PodMan saw, flagged, and learned. Drag to rearrange.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{node.kind}
|
||||
</p>
|
||||
<h3 className="mb-2 mt-0.5 font-heading text-lg font-medium">{node.label}</h3>
|
||||
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
|
||||
<span>Status</span>
|
||||
<Badge variant="outline" style={{ color: statusColor(node.status) }}>
|
||||
{node.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b py-1.5 text-sm text-muted-foreground">
|
||||
<span>Relationships</span>
|
||||
<span className="font-medium text-foreground">{relCount}</span>
|
||||
</div>
|
||||
{flow && (
|
||||
<>
|
||||
<p className="mt-2.5 text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Flow
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-relaxed text-foreground/90">{flow}</p>
|
||||
</>
|
||||
)}
|
||||
{node.summary && node.summary !== flow && (
|
||||
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">{node.summary}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type {
|
||||
PodGraph,
|
||||
PodGraphNode,
|
||||
PodGraphEdge,
|
||||
PodGraphNodeKind,
|
||||
ActivityKind,
|
||||
} from '@podman/shared';
|
||||
|
||||
/**
|
||||
* Fixed, light-readable hues for the node/edge encoding. Kept stable across
|
||||
* light/dark so kinds stay distinguishable; only the chrome uses shadcn tokens.
|
||||
*/
|
||||
export const BLUE = '#2563eb';
|
||||
export const SLATE = '#475569';
|
||||
export const SLATE_EDGE = '#94a3b8';
|
||||
export const SLATE_FAINT = '#cbd5e1';
|
||||
export const AMBER = '#d97706';
|
||||
export const RED = '#dc2626';
|
||||
export const VIOLET = '#7c3aed';
|
||||
export const GREEN = '#16a34a';
|
||||
|
||||
/** Tag color + short label per activity-stream kind. */
|
||||
export const ACTIVITY_TAG: Record<ActivityKind, { color: string; label: string }> = {
|
||||
editing: { color: SLATE, label: 'EDITING' },
|
||||
collision: { color: RED, label: 'COLLISION' },
|
||||
warns: { color: AMBER, label: 'WARNS' },
|
||||
outcome: { color: GREEN, label: 'OUTCOME' },
|
||||
learned_from: { color: VIOLET, label: 'LEARNED' },
|
||||
};
|
||||
|
||||
export const KIND_COLOR: Record<PodGraphNodeKind, string> = {
|
||||
engineer: BLUE,
|
||||
file: SLATE,
|
||||
feature: AMBER,
|
||||
collision: RED,
|
||||
intervention: VIOLET,
|
||||
};
|
||||
|
||||
export interface EdgeStyle {
|
||||
c: string;
|
||||
w: number;
|
||||
dash?: boolean;
|
||||
}
|
||||
|
||||
export const EDGE: Record<PodGraphEdge['kind'], EdgeStyle> = {
|
||||
owns: { c: BLUE, w: 2.4 },
|
||||
editing: { c: SLATE_EDGE, w: 1.9 },
|
||||
touches: { c: SLATE_FAINT, w: 1.5 },
|
||||
collides: { c: RED, w: 2.8 },
|
||||
warns: { c: AMBER, w: 2.8 },
|
||||
learned_from: { c: VIOLET, w: 2.4, dash: true },
|
||||
};
|
||||
|
||||
/** Collision/drawing radius for a node — scaled by its 0..1 weight. */
|
||||
export function nodeRadius(node: PodGraphNode): number {
|
||||
const base = node.kind === 'collision' || node.kind === 'intervention' ? 14 : 13;
|
||||
return base + Math.max(0, Math.min(1, node.weight)) * 7;
|
||||
}
|
||||
|
||||
export function statusColor(status: string): string {
|
||||
if (status === 'risk') return RED;
|
||||
if (status === 'learned') return VIOLET;
|
||||
if (status === 'active') return BLUE;
|
||||
return 'var(--muted-foreground)';
|
||||
}
|
||||
|
||||
export type Mode = 'risk' | 'learn' | 'all';
|
||||
|
||||
export interface Highlight {
|
||||
nodes: Set<string>;
|
||||
edges: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The lit set for the current mode/selection. A selected node lights its
|
||||
* incident edges + neighbors; otherwise the mode lights the risk or learning
|
||||
* chain (collision → intervention → learned_from). `all` lights everything.
|
||||
*/
|
||||
export function highlightFor(graph: PodGraph, mode: Mode, selected: string | null): Highlight | null {
|
||||
if (selected) {
|
||||
const es = graph.edges.filter((e) => e.source === selected || e.target === selected);
|
||||
return {
|
||||
nodes: new Set([selected, ...es.flatMap((e) => [e.source, e.target])]),
|
||||
edges: new Set(es.map((e) => e.id)),
|
||||
};
|
||||
}
|
||||
if (mode === 'all') return null;
|
||||
const kinds: PodGraphEdge['kind'][] =
|
||||
mode === 'risk' ? ['collides', 'warns', 'learned_from'] : ['learned_from', 'warns'];
|
||||
const collisions = new Set(graph.nodes.filter((n) => n.kind === 'collision').map((n) => n.id));
|
||||
const es = graph.edges.filter(
|
||||
(e) =>
|
||||
kinds.includes(e.kind) ||
|
||||
(mode === 'risk' && (collisions.has(e.target) || collisions.has(e.source))),
|
||||
);
|
||||
return {
|
||||
nodes: new Set(es.flatMap((e) => [e.source, e.target])),
|
||||
edges: new Set(es.map((e) => e.id)),
|
||||
};
|
||||
}
|
||||
|
||||
function joinNames(ids: string[], label: (id: string) => string): string {
|
||||
const u = [...new Set(ids)].map(label);
|
||||
if (u.length <= 1) return u[0] ?? '';
|
||||
if (u.length === 2) return `${u[0]} and ${u[1]}`;
|
||||
return `${u.slice(0, -1).join(', ')} and ${u[u.length - 1]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain-English walk of the flow through a node — what PodMan saw, flagged,
|
||||
* suggested, and learned — so clicking a node explains the path, not just shows
|
||||
* attributes. Built by traversing the node's incident edges.
|
||||
*/
|
||||
export function flowNarrative(graph: PodGraph, nodeId: string): string {
|
||||
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
||||
const node = byId.get(nodeId);
|
||||
if (!node) return '';
|
||||
const label = (id: string): string => byId.get(id)?.label ?? id;
|
||||
const out = graph.edges.filter((e) => e.source === nodeId);
|
||||
const inc = graph.edges.filter((e) => e.target === nodeId);
|
||||
|
||||
switch (node.kind) {
|
||||
case 'engineer': {
|
||||
const edits = out.filter((e) => e.kind === 'editing').map((e) => e.target);
|
||||
const collisions = out.filter((e) => e.kind === 'collides');
|
||||
const owns = out.filter((e) => e.kind === 'owns').map((e) => label(e.target));
|
||||
const learned = inc.some((e) => e.kind === 'learned_from');
|
||||
const parts: string[] = [];
|
||||
if (edits.length) parts.push(`${node.label} is working in ${joinNames(edits, label)}.`);
|
||||
if (collisions.length)
|
||||
parts.push(
|
||||
`PodMan flagged ${collisions.length} overlap${collisions.length === 1 ? '' : 's'} involving ${node.label}.`,
|
||||
);
|
||||
if (learned)
|
||||
parts.push(
|
||||
`From an accepted intervention PodMan learned ${node.label} owns ${owns[0] ?? 'this file'} — retained across sessions.`,
|
||||
);
|
||||
else if (owns.length) parts.push(`PodMan has ${node.label} owning ${joinNames(owns, (s) => s)}.`);
|
||||
return parts.join(' ') || `${node.label} has no active flow right now.`;
|
||||
}
|
||||
case 'file': {
|
||||
const editors = inc.filter((e) => e.kind === 'editing' || e.kind === 'owns').map((e) => e.source);
|
||||
const hasCollision = out.some((e) => e.kind === 'touches');
|
||||
const parts: string[] = [];
|
||||
if (editors.length) parts.push(`${node.label} is being edited by ${joinNames(editors, label)}.`);
|
||||
if (hasCollision)
|
||||
parts.push('Two of those edits overlap before push, so PodMan opened a collision on it.');
|
||||
return parts.join(' ') || node.summary || node.label;
|
||||
}
|
||||
case 'collision': {
|
||||
const engineers = inc.filter((e) => e.kind === 'collides').map((e) => e.source);
|
||||
const fileEdge = inc.find((e) => e.kind === 'touches');
|
||||
const file = fileEdge ? label(fileEdge.source) : 'the same file';
|
||||
const intervention = out.find((e) => e.kind === 'warns');
|
||||
let s = `${joinNames(engineers, label) || 'Two engineers'} are both editing ${file} before pushing — the overlap git can't see.`;
|
||||
if (intervention) s += ` PodMan stepped in and suggested a ${label(intervention.target)}.`;
|
||||
return s;
|
||||
}
|
||||
case 'intervention': {
|
||||
const colEdge = inc.find((e) => e.kind === 'warns');
|
||||
const learned = out.find((e) => e.kind === 'learned_from');
|
||||
// Resolve the collision's underlying file via its touches edge (file → collision).
|
||||
let file = '';
|
||||
if (colEdge) {
|
||||
const fileEdge = graph.edges.find((e) => e.kind === 'touches' && e.target === colEdge.source);
|
||||
file = fileEdge ? label(fileEdge.source) : '';
|
||||
}
|
||||
let s = `PodMan offered a ${node.label}${file ? ` for the overlap on ${file}` : ''}.`;
|
||||
if (learned)
|
||||
s += ` The pod accepted it, so PodMan learned ${label(learned.target)} owns ${file || 'the file'} — the graph got sharper.`;
|
||||
return s;
|
||||
}
|
||||
case 'feature': {
|
||||
const contributors = inc.filter((e) => e.kind === 'owns' || e.kind === 'touches').map((e) => e.source);
|
||||
return contributors.length
|
||||
? `${node.label} is built on work by ${joinNames(contributors, label)}.`
|
||||
: node.summary || node.label;
|
||||
}
|
||||
default:
|
||||
return node.summary ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Short explainer for the current view when nothing is selected. */
|
||||
export function modeBlurb(mode: Mode): string {
|
||||
if (mode === 'learn')
|
||||
return 'The violet learned_from links are ownership PodMan kept from accepted interventions — the graph sharpens every session.';
|
||||
if (mode === 'all')
|
||||
return 'Everyone, every file, and every collision and intervention PodMan is tracking for this pod.';
|
||||
return 'The lit path: files where two editors collide before push → the nudge PodMan sent → what it learned.';
|
||||
}
|
||||
|
||||
export const NODE_LEGEND: Array<{ label: string; swatch: CSSProperties }> = [
|
||||
{ label: 'engineer', swatch: { background: BLUE } },
|
||||
{ label: 'file', swatch: { border: `2px solid ${SLATE}` } },
|
||||
{ label: 'feature', swatch: { background: AMBER, borderRadius: '50%' } },
|
||||
{ label: 'collision', swatch: { background: RED, clipPath: 'polygon(50% 0,100% 100%,0 100%)' } },
|
||||
{ label: 'intervention', swatch: { background: VIOLET, transform: 'rotate(45deg)' } },
|
||||
];
|
||||
|
||||
export const EDGE_LEGEND: Array<{ label: string; color: string; dash?: boolean }> = [
|
||||
{ label: 'collides', color: RED },
|
||||
{ label: 'warns', color: AMBER },
|
||||
{ label: 'learned_from', color: VIOLET, dash: true },
|
||||
{ label: 'owns', color: BLUE },
|
||||
{ label: 'editing', color: SLATE_EDGE },
|
||||
{ label: 'touches', color: SLATE_FAINT },
|
||||
];
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* A tiny dependency-free force-directed layout — the same family of forces as
|
||||
* d3-force (charge repulsion, link springs, centering, collision) integrated
|
||||
* with velocity-Verlet and an annealing `alpha`. Kept in-house so the dynamic
|
||||
* graph adds no new package / lockfile churn to a fast-moving shared `main`.
|
||||
*
|
||||
* Usage: `setData()` (diff-preserving — existing nodes keep their position),
|
||||
* then drive `tick()` from a requestAnimationFrame loop until `settled()`.
|
||||
*/
|
||||
|
||||
export interface SimNodeInput {
|
||||
id: string;
|
||||
/** Drawing/collision radius. */
|
||||
radius: number;
|
||||
/** Initial position hint (e.g. the server layout), used only for new nodes. */
|
||||
seedX: number;
|
||||
seedY: number;
|
||||
}
|
||||
|
||||
export interface SimLinkInput {
|
||||
source: string;
|
||||
target: string;
|
||||
/** Preferred rest length of the spring. */
|
||||
distance: number;
|
||||
/** 0..1 spring strength. */
|
||||
strength: number;
|
||||
}
|
||||
|
||||
export interface SimNode {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
/** When non-null the node is pinned (dragged) and forces don't move it. */
|
||||
fx: number | null;
|
||||
fy: number | null;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
const ALPHA_MIN = 0.001;
|
||||
const ALPHA_DECAY = 1 - Math.pow(ALPHA_MIN, 1 / 300); // settle in ~300 ticks
|
||||
const FRICTION = 0.62; // velocity retained per tick
|
||||
const REPEL = 4400; // charge repulsion strength — must dominate centering or the graph collapses
|
||||
const LINK_K = 0.45; // spring stiffness multiplier
|
||||
const CENTER_STRENGTH = 0.014; // gentle positional pull — only keeps the cloud roughly centered
|
||||
const RECENTER = 0.5; // per-tick centroid recentering (no compression, keeps graph framed)
|
||||
const COLLIDE_PAD = 12;
|
||||
const COLLIDE_STRENGTH = 1; // hard separation so linked nodes never stack
|
||||
const COLLIDE_ITERS = 2;
|
||||
const BOUND_PAD = 30; // keep nodes this far inside the canvas edges
|
||||
|
||||
export class ForceSim {
|
||||
nodes: SimNode[] = [];
|
||||
links: SimLinkInput[] = [];
|
||||
alpha = 1;
|
||||
private byId = new Map<string, SimNode>();
|
||||
private alphaTarget = 0;
|
||||
private center: { x: number; y: number };
|
||||
private width: number;
|
||||
private height: number;
|
||||
|
||||
constructor(width: number, height: number) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.center = { x: width / 2, y: height / 2 };
|
||||
}
|
||||
|
||||
settled(): boolean {
|
||||
return this.alpha < ALPHA_MIN && this.alphaTarget === 0;
|
||||
}
|
||||
|
||||
reheat(a = 0.7): void {
|
||||
this.alpha = Math.max(this.alpha, a);
|
||||
}
|
||||
|
||||
/** Hold the simulation warm while dragging, then release. */
|
||||
setActive(active: boolean): void {
|
||||
this.alphaTarget = active ? 0.18 : 0;
|
||||
if (active) this.reheat(0.25);
|
||||
}
|
||||
|
||||
get(id: string): SimNode | undefined {
|
||||
return this.byId.get(id);
|
||||
}
|
||||
|
||||
pin(id: string, x: number, y: number): void {
|
||||
const n = this.byId.get(id);
|
||||
if (n) {
|
||||
n.fx = x;
|
||||
n.fy = y;
|
||||
}
|
||||
}
|
||||
|
||||
unpin(id: string): void {
|
||||
const n = this.byId.get(id);
|
||||
if (n) {
|
||||
n.fx = null;
|
||||
n.fy = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the graph, preserving the positions/pins of nodes that persist. */
|
||||
setData(nodeInputs: SimNodeInput[], linkInputs: SimLinkInput[]): { added: string[] } {
|
||||
const prev = this.byId;
|
||||
const next = new Map<string, SimNode>();
|
||||
const added: string[] = [];
|
||||
for (const inp of nodeInputs) {
|
||||
const old = prev.get(inp.id);
|
||||
if (old) {
|
||||
old.radius = inp.radius;
|
||||
next.set(inp.id, old);
|
||||
} else {
|
||||
next.set(inp.id, {
|
||||
id: inp.id,
|
||||
x: inp.seedX + (Math.random() - 0.5) * 14,
|
||||
y: inp.seedY + (Math.random() - 0.5) * 14,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
fx: null,
|
||||
fy: null,
|
||||
radius: inp.radius,
|
||||
});
|
||||
added.push(inp.id);
|
||||
}
|
||||
}
|
||||
this.byId = next;
|
||||
this.nodes = [...next.values()];
|
||||
this.links = linkInputs.filter((l) => next.has(l.source) && next.has(l.target));
|
||||
return { added };
|
||||
}
|
||||
|
||||
/** Advance one step. Returns false when already settled (no work done). */
|
||||
tick(): boolean {
|
||||
if (this.settled()) return false;
|
||||
this.alpha += (this.alphaTarget - this.alpha) * ALPHA_DECAY;
|
||||
const a = this.alpha;
|
||||
this.applyCharge(a);
|
||||
this.applyLinks(a);
|
||||
this.applyCenter(a);
|
||||
for (let k = 0; k < COLLIDE_ITERS; k++) this.applyCollide();
|
||||
const maxX = this.width - BOUND_PAD;
|
||||
const maxY = this.height - BOUND_PAD;
|
||||
for (const n of this.nodes) {
|
||||
if (n.fx != null) {
|
||||
n.x = n.fx;
|
||||
n.vx = 0;
|
||||
} else {
|
||||
n.vx *= FRICTION;
|
||||
n.x += n.vx;
|
||||
if (n.x < BOUND_PAD) {
|
||||
n.x = BOUND_PAD;
|
||||
n.vx = 0;
|
||||
} else if (n.x > maxX) {
|
||||
n.x = maxX;
|
||||
n.vx = 0;
|
||||
}
|
||||
}
|
||||
if (n.fy != null) {
|
||||
n.y = n.fy;
|
||||
n.vy = 0;
|
||||
} else {
|
||||
n.vy *= FRICTION;
|
||||
n.y += n.vy;
|
||||
if (n.y < BOUND_PAD) {
|
||||
n.y = BOUND_PAD;
|
||||
n.vy = 0;
|
||||
} else if (n.y > maxY) {
|
||||
n.y = maxY;
|
||||
n.vy = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private applyCharge(alpha: number): void {
|
||||
const ns = this.nodes;
|
||||
for (let i = 0; i < ns.length; i++) {
|
||||
const a = ns[i];
|
||||
if (!a) continue;
|
||||
for (let j = i + 1; j < ns.length; j++) {
|
||||
const b = ns[j];
|
||||
if (!b) continue;
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 === 0) {
|
||||
dx = (j - i) * 0.5;
|
||||
dy = (i + 1) * 0.4;
|
||||
d2 = dx * dx + dy * dy;
|
||||
}
|
||||
const dist = Math.sqrt(d2);
|
||||
const force = (REPEL * alpha) / d2;
|
||||
const ux = dx / dist;
|
||||
const uy = dy / dist;
|
||||
a.vx -= ux * force;
|
||||
a.vy -= uy * force;
|
||||
b.vx += ux * force;
|
||||
b.vy += uy * force;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyLinks(alpha: number): void {
|
||||
for (const link of this.links) {
|
||||
const s = this.byId.get(link.source);
|
||||
const t = this.byId.get(link.target);
|
||||
if (!s || !t) continue;
|
||||
let dx = t.x - s.x;
|
||||
let dy = t.y - s.y;
|
||||
let d2 = dx * dx + dy * dy;
|
||||
if (d2 === 0) {
|
||||
dx = 0.5;
|
||||
dy = 0.5;
|
||||
d2 = 0.5;
|
||||
}
|
||||
const dist = Math.sqrt(d2);
|
||||
const k = ((dist - link.distance) / dist) * alpha * link.strength * LINK_K;
|
||||
const mx = dx * k * 0.5;
|
||||
const my = dy * k * 0.5;
|
||||
s.vx += mx;
|
||||
s.vy += my;
|
||||
t.vx -= mx;
|
||||
t.vy -= my;
|
||||
}
|
||||
}
|
||||
|
||||
private applyCenter(alpha: number): void {
|
||||
const n = this.nodes.length;
|
||||
if (!n) return;
|
||||
// Recenter the whole cloud so its centroid sits at canvas center (this does
|
||||
// NOT compress the layout — repulsion/links set the spread), plus a gentle
|
||||
// positional pull so stray/isolated nodes don't park against the edge.
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (const nd of this.nodes) {
|
||||
cx += nd.x;
|
||||
cy += nd.y;
|
||||
}
|
||||
cx = (this.center.x - cx / n) * RECENTER;
|
||||
cy = (this.center.y - cy / n) * RECENTER;
|
||||
for (const nd of this.nodes) {
|
||||
if (nd.fx == null) {
|
||||
nd.x += cx;
|
||||
nd.vx += (this.center.x - nd.x) * CENTER_STRENGTH * alpha;
|
||||
}
|
||||
if (nd.fy == null) {
|
||||
nd.y += cy;
|
||||
nd.vy += (this.center.y - nd.y) * CENTER_STRENGTH * alpha;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private applyCollide(): void {
|
||||
const ns = this.nodes;
|
||||
for (let i = 0; i < ns.length; i++) {
|
||||
const a = ns[i];
|
||||
if (!a) continue;
|
||||
for (let j = i + 1; j < ns.length; j++) {
|
||||
const b = ns[j];
|
||||
if (!b) continue;
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
const min = a.radius + b.radius + COLLIDE_PAD;
|
||||
if (d2 >= min * min) continue;
|
||||
let dist = Math.sqrt(d2);
|
||||
if (dist === 0) {
|
||||
dx = j - i;
|
||||
dy = i + 1;
|
||||
dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
}
|
||||
const push = ((min - dist) / dist) * 0.5 * COLLIDE_STRENGTH;
|
||||
const ox = dx * push;
|
||||
const oy = dy * push;
|
||||
if (a.fx == null) a.x -= ox;
|
||||
if (a.fy == null) a.y -= oy;
|
||||
if (b.fx == null) b.x += ox;
|
||||
if (b.fy == null) b.y += oy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,8 @@ export async function fetchPodGraph(podId: string): Promise<PodGraph> {
|
||||
if (!res.ok) throw new Error(`graph request failed: ${res.status}`);
|
||||
return res.json() as Promise<PodGraph>;
|
||||
}
|
||||
|
||||
/** WebSocket URL for the live event bus — used to nudge the graph to refetch. */
|
||||
export function backendEventsUrl(): string {
|
||||
return `${BACKEND_URL.replace(/^http/, 'ws')}/api/events`;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,36 @@ export interface PodGraphMetric {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/** The five stages of PodMan's continual-learning loop, in order. */
|
||||
export type LearningStageKey = 'observe' | 'store' | 'predict' | 'outcome' | 'adapt';
|
||||
|
||||
/** One stage of the learning-loop rail (observe→store→predict→outcome→adapt). */
|
||||
export interface LearningStage {
|
||||
key: LearningStageKey;
|
||||
/** UPPERCASE display title, e.g. "OBSERVE". */
|
||||
title: string;
|
||||
/** Headline figure for the stage, e.g. "5/s" or "124". */
|
||||
value: string;
|
||||
/** One-line detail under the title. */
|
||||
detail: string;
|
||||
/** True for the single most-recently-active stage (pulses in the UI). */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** Kind of an activity-stream entry (drives the colored tag). */
|
||||
export type ActivityKind = 'editing' | 'collision' | 'warns' | 'outcome' | 'learned_from';
|
||||
|
||||
/** One time-tagged entry in the activity stream. */
|
||||
export interface ActivityEvent {
|
||||
/** Stable id (source doc id + kind) so the UI can animate diffs. */
|
||||
id: string;
|
||||
/** ISO timestamp the event happened. */
|
||||
at: string;
|
||||
kind: ActivityKind;
|
||||
/** Human-readable line, e.g. "Yahya opened auth.ts — unpushed changes". */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** A point-in-time render of a pod's team_model. */
|
||||
export interface PodGraph {
|
||||
podId: string;
|
||||
@@ -56,6 +86,10 @@ export interface PodGraph {
|
||||
nodes: PodGraphNode[];
|
||||
edges: PodGraphEdge[];
|
||||
metrics: PodGraphMetric[];
|
||||
/** Continual-learning loop counts (observe→…→adapt). Additive/optional. */
|
||||
loop?: LearningStage[];
|
||||
/** Recent activity feed, most-recent first, capped ~8. Additive/optional. */
|
||||
activity?: ActivityEvent[];
|
||||
}
|
||||
|
||||
/** One node as a standalone document in the `graph_nodes` collection. */
|
||||
|
||||
@@ -18,6 +18,10 @@ export type {
|
||||
PodGraphNodeKind,
|
||||
PodGraphEdgeKind,
|
||||
PodGraphNodeStatus,
|
||||
LearningStage,
|
||||
LearningStageKey,
|
||||
ActivityEvent,
|
||||
ActivityKind,
|
||||
GraphNodeDoc,
|
||||
GraphEdgeDoc,
|
||||
} from './graph.js';
|
||||
|
||||
Reference in New Issue
Block a user