From 45bbbe155756bb3c4ea9614071f1b15a272eb956 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Sat, 14 Mar 2026 20:03:12 +0800 Subject: [PATCH] docs: add Phase 3 (Learn Mode) and Phase 4 (Advanced) implementation plans Phase 3 (7 tasks): Tour generation engine, LearnPanel + tour store, tour player with graph highlighting, contextual node explanations, language lesson prompts, enhanced language display, persona modes. Phase 4 (7 tasks): /understand-diff, /understand-explain, /understand-onboard skills, plugin registry + discovery, embedding-based semantic search + dashboard integration. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-14-phase3-implementation.md | 1658 +++++++++++++++ .../plans/2026-03-14-phase4-implementation.md | 1872 +++++++++++++++++ 2 files changed, 3530 insertions(+) create mode 100644 docs/plans/2026-03-14-phase3-implementation.md create mode 100644 docs/plans/2026-03-14-phase4-implementation.md diff --git a/docs/plans/2026-03-14-phase3-implementation.md b/docs/plans/2026-03-14-phase3-implementation.md new file mode 100644 index 0000000..1bf4c8a --- /dev/null +++ b/docs/plans/2026-03-14-phase3-implementation.md @@ -0,0 +1,1658 @@ +# Understand Anything — Phase 3 (Learn Mode) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add the "Learn Mode" layer — tour generation, contextual explanations, language-specific lessons, and persona modes (non-technical / junior / experienced). + +**Architecture:** Extends the existing monorepo. Core gets tour generation and language lesson prompt builders. Dashboard gets a new LearnPanel component, persona selector, and enhanced node explanation. The existing 4-panel layout becomes persona-adaptive. + +**Tech Stack:** No new dependencies required. Uses existing react-markdown, @anthropic-ai/sdk, zustand, @xyflow/react, tailwindcss. + +--- + +## Dependency Graph + +``` +Task 1 (Tour Gen Core) ──────────────┐ + ├─→ Task 3 (Tour Player + Highlights) +Task 2 (LearnPanel + Store) ─────────┘ │ + │ +Task 4 (Node Explanations) ─── (independent) ───┤ + │ +Task 5 (Language Lesson Core) ───────────────────┤ + ├─→ Task 7 (Persona Modes) +Task 6 (Language Lesson Display) ────────────────┘ +``` + +Tasks 1, 2, 4, 5 can be developed in any order. Task 3 depends on Task 2. Task 6 depends on Task 5. Task 7 depends on Tasks 2+3+6 being complete. + +--- + +## Task 1: Tour Generation Engine (Core) + +**Files:** +- Create: `packages/core/src/analyzer/tour-generator.ts` +- Create: `packages/core/src/__tests__/tour-generator.test.ts` +- Modify: `packages/core/src/index.ts` (add exports) + +**Context:** The `TourStep` schema already exists in `packages/core/src/types.ts` (order, title, description, nodeIds, languageLesson?). The sample `knowledge-graph.json` already has 6 tour steps with language lessons. This task builds the engine that GENERATES those tours: an LLM prompt builder + response parser, and a heuristic fallback that creates tours without an LLM by using graph topology (entry-point detection → topological sort → group by layers). + +**Step 1: Write failing tests** + +```typescript +// packages/core/src/__tests__/tour-generator.test.ts +import { describe, it, expect } from "vitest"; +import { + buildTourGenerationPrompt, + parseTourGenerationResponse, + generateHeuristicTour, +} from "../analyzer/tour-generator.js"; +import type { KnowledgeGraph } from "../types.js"; + +const sampleGraph: KnowledgeGraph = { + version: "1.0.0", + project: { + name: "test-project", + languages: ["typescript"], + frameworks: ["express"], + description: "A test project", + analyzedAt: "2026-03-14T00:00:00Z", + gitCommitHash: "abc123", + }, + nodes: [ + { + id: "file:src/index.ts", + type: "file", + name: "index.ts", + filePath: "src/index.ts", + summary: "Application entry point", + tags: ["entry", "server"], + complexity: "simple", + }, + { + id: "file:src/routes.ts", + type: "file", + name: "routes.ts", + filePath: "src/routes.ts", + summary: "Route definitions", + tags: ["routes", "api"], + complexity: "moderate", + }, + { + id: "file:src/service.ts", + type: "file", + name: "service.ts", + filePath: "src/service.ts", + summary: "Business logic", + tags: ["service"], + complexity: "complex", + }, + { + id: "file:src/db.ts", + type: "file", + name: "db.ts", + filePath: "src/db.ts", + summary: "Database connection", + tags: ["database"], + complexity: "simple", + }, + { + id: "concept:auth-flow", + type: "concept", + name: "Auth Flow", + summary: "Authentication concept", + tags: ["concept", "auth"], + complexity: "moderate", + }, + ], + edges: [ + { source: "file:src/index.ts", target: "file:src/routes.ts", type: "imports", direction: "forward", weight: 0.9 }, + { source: "file:src/routes.ts", target: "file:src/service.ts", type: "calls", direction: "forward", weight: 0.8 }, + { source: "file:src/service.ts", target: "file:src/db.ts", type: "reads_from", direction: "forward", weight: 0.7 }, + ], + layers: [ + { id: "layer:api", name: "API Layer", description: "HTTP routes", nodeIds: ["file:src/index.ts", "file:src/routes.ts"] }, + { id: "layer:service", name: "Service Layer", description: "Business logic", nodeIds: ["file:src/service.ts"] }, + { id: "layer:data", name: "Data Layer", description: "Database", nodeIds: ["file:src/db.ts"] }, + ], + tour: [], +}; + +describe("tour-generator", () => { + describe("buildTourGenerationPrompt", () => { + it("includes project name and description", () => { + const prompt = buildTourGenerationPrompt(sampleGraph); + expect(prompt).toContain("test-project"); + expect(prompt).toContain("A test project"); + }); + + it("includes all node summaries", () => { + const prompt = buildTourGenerationPrompt(sampleGraph); + expect(prompt).toContain("index.ts"); + expect(prompt).toContain("routes.ts"); + expect(prompt).toContain("service.ts"); + }); + + it("includes layer information", () => { + const prompt = buildTourGenerationPrompt(sampleGraph); + expect(prompt).toContain("API Layer"); + expect(prompt).toContain("Service Layer"); + }); + + it("requests JSON output format", () => { + const prompt = buildTourGenerationPrompt(sampleGraph); + expect(prompt).toContain("JSON"); + }); + }); + + describe("parseTourGenerationResponse", () => { + it("parses valid JSON response with tour steps", () => { + const response = JSON.stringify({ + steps: [ + { order: 1, title: "Entry Point", description: "Start here", nodeIds: ["file:src/index.ts"] }, + { order: 2, title: "Routing", description: "Routes next", nodeIds: ["file:src/routes.ts"], languageLesson: "Express uses middleware" }, + ], + }); + const steps = parseTourGenerationResponse(response); + expect(steps).toHaveLength(2); + expect(steps[0].title).toBe("Entry Point"); + expect(steps[1].languageLesson).toBe("Express uses middleware"); + }); + + it("extracts JSON from markdown code blocks", () => { + const response = "Here is the tour:\n```json\n" + JSON.stringify({ + steps: [{ order: 1, title: "Step 1", description: "Desc", nodeIds: ["n1"] }], + }) + "\n```"; + const steps = parseTourGenerationResponse(response); + expect(steps).toHaveLength(1); + }); + + it("returns empty array for unparseable response", () => { + const steps = parseTourGenerationResponse("not json at all"); + expect(steps).toEqual([]); + }); + + it("filters out steps with missing required fields", () => { + const response = JSON.stringify({ + steps: [ + { order: 1, title: "Valid", description: "OK", nodeIds: ["n1"] }, + { order: 2, description: "Missing title", nodeIds: ["n2"] }, + { order: 3, title: "Missing desc", nodeIds: ["n3"] }, + ], + }); + const steps = parseTourGenerationResponse(response); + expect(steps).toHaveLength(1); + expect(steps[0].title).toBe("Valid"); + }); + }); + + describe("generateHeuristicTour", () => { + it("starts with entry-point nodes", () => { + const tour = generateHeuristicTour(sampleGraph); + expect(tour.length).toBeGreaterThan(0); + // index.ts has no incoming edges → entry point + expect(tour[0].nodeIds).toContain("file:src/index.ts"); + }); + + it("follows topological order", () => { + const tour = generateHeuristicTour(sampleGraph); + const allNodeIds = tour.flatMap((s) => s.nodeIds); + const indexPos = allNodeIds.indexOf("file:src/index.ts"); + const routesPos = allNodeIds.indexOf("file:src/routes.ts"); + const servicePos = allNodeIds.indexOf("file:src/service.ts"); + // entry → routes → service (topological order) + expect(indexPos).toBeLessThan(routesPos); + expect(routesPos).toBeLessThan(servicePos); + }); + + it("includes concept nodes in separate steps", () => { + const tour = generateHeuristicTour(sampleGraph); + const conceptStep = tour.find((s) => + s.nodeIds.includes("concept:auth-flow"), + ); + expect(conceptStep).toBeDefined(); + }); + + it("assigns order numbers sequentially", () => { + const tour = generateHeuristicTour(sampleGraph); + tour.forEach((step, i) => { + expect(step.order).toBe(i + 1); + }); + }); + + it("groups nodes by layer when layers exist", () => { + const tour = generateHeuristicTour(sampleGraph); + // Steps should roughly follow layer boundaries + expect(tour.length).toBeGreaterThanOrEqual(3); + }); + + it("produces valid TourStep objects", () => { + const tour = generateHeuristicTour(sampleGraph); + for (const step of tour) { + expect(step).toHaveProperty("order"); + expect(step).toHaveProperty("title"); + expect(step).toHaveProperty("description"); + expect(step).toHaveProperty("nodeIds"); + expect(step.title.length).toBeGreaterThan(0); + expect(step.description.length).toBeGreaterThan(0); + expect(step.nodeIds.length).toBeGreaterThan(0); + } + }); + + it("handles graph with no edges gracefully", () => { + const isolated = { ...sampleGraph, edges: [] }; + const tour = generateHeuristicTour(isolated); + expect(tour.length).toBeGreaterThan(0); + }); + + it("handles graph with no layers", () => { + const noLayers = { ...sampleGraph, layers: [] }; + const tour = generateHeuristicTour(noLayers); + expect(tour.length).toBeGreaterThan(0); + }); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd packages/core && pnpm test -- --reporter verbose src/__tests__/tour-generator.test.ts +``` + +Expected: FAIL — module not found + +**Step 3: Implement tour-generator.ts** + +```typescript +// packages/core/src/analyzer/tour-generator.ts +import type { KnowledgeGraph, TourStep, GraphNode, GraphEdge } from "../types.js"; + +/** + * Build an LLM prompt that asks for a guided tour of the project. + */ +export function buildTourGenerationPrompt(graph: KnowledgeGraph): string { + const { project, nodes, edges, layers } = graph; + + const nodeList = nodes + .map((n) => `- [${n.type}] ${n.name}${n.filePath ? ` (${n.filePath})` : ""}: ${n.summary}`) + .join("\n"); + + const edgeList = edges + .slice(0, 50) // cap to avoid overly long prompts + .map((e) => { + const src = nodes.find((n) => n.id === e.source)?.name ?? e.source; + const tgt = nodes.find((n) => n.id === e.target)?.name ?? e.target; + return `- ${src} --[${e.type}]--> ${tgt}`; + }) + .join("\n"); + + const layerList = layers.length > 0 + ? layers.map((l) => `- ${l.name}: ${l.description} (${l.nodeIds.length} nodes)`).join("\n") + : "No layers defined"; + + return [ + `You are generating a guided tour for a software project called "${project.name}".`, + ``, + `Project description: ${project.description}`, + `Languages: ${project.languages.join(", ")}`, + `Frameworks: ${project.frameworks.join(", ")}`, + ``, + `## Nodes`, + nodeList, + ``, + `## Relationships`, + edgeList, + ``, + `## Layers`, + layerList, + ``, + `## Instructions`, + `Create a guided tour of this project. Each step should:`, + `1. Focus on 1-4 nodes that belong together conceptually`, + `2. Have a clear, engaging title (like "Where It All Begins" not "Step 1")`, + `3. Explain in plain English what these components do and WHY they exist`, + `4. Follow the natural execution flow (entry point → routing → business logic → data)`, + `5. Include a languageLesson field for steps that use language-specific concepts`, + ` (e.g., middleware, generics, async/await, decorators — explain them simply)`, + ``, + `Return JSON in exactly this format:`, + `\`\`\`json`, + `{`, + ` "steps": [`, + ` {`, + ` "order": 1,`, + ` "title": "Engaging Step Title",`, + ` "description": "Markdown explanation of what these nodes do.",`, + ` "nodeIds": ["node-id-1", "node-id-2"],`, + ` "languageLesson": "Optional: explain a language concept used here"`, + ` }`, + ` ]`, + `}`, + `\`\`\``, + ``, + `Create 4-8 steps covering the full project. Use actual node IDs from the list above.`, + ].join("\n"); +} + +/** + * Parse the LLM response into TourStep[]. + * Handles raw JSON, JSON in markdown code blocks, and graceful fallback. + */ +export function parseTourGenerationResponse(response: string): TourStep[] { + let json: string = response; + + // Extract from markdown code blocks if present + const codeBlockMatch = response.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/); + if (codeBlockMatch) { + json = codeBlockMatch[1]; + } + + try { + const parsed = JSON.parse(json); + const rawSteps: unknown[] = Array.isArray(parsed) ? parsed : parsed?.steps; + if (!Array.isArray(rawSteps)) return []; + + return rawSteps.filter((s): s is TourStep => { + if (typeof s !== "object" || s === null) return false; + const step = s as Record; + return ( + typeof step.order === "number" && + typeof step.title === "string" && + step.title.length > 0 && + typeof step.description === "string" && + step.description.length > 0 && + Array.isArray(step.nodeIds) && + step.nodeIds.length > 0 + ); + }).map((s) => ({ + order: s.order, + title: s.title, + description: s.description, + nodeIds: s.nodeIds, + ...(s.languageLesson ? { languageLesson: s.languageLesson } : {}), + })); + } catch { + return []; + } +} + +/** + * Generate a tour using heuristics only (no LLM required). + * + * Strategy: + * 1. Find entry-point nodes (no incoming edges, or named index/main/app) + * 2. Topological sort from entry points + * 3. Group by layer (if layers exist) or by execution depth + * 4. Add concept nodes as separate explanatory steps + */ +export function generateHeuristicTour(graph: KnowledgeGraph): TourStep[] { + const { nodes, edges, layers } = graph; + + // Separate concept nodes from code nodes + const codeNodes = nodes.filter((n) => n.type !== "concept"); + const conceptNodes = nodes.filter((n) => n.type === "concept"); + + // Build adjacency info + const incomingCount = new Map(); + const adjacency = new Map(); + for (const node of codeNodes) { + incomingCount.set(node.id, 0); + adjacency.set(node.id, []); + } + for (const edge of edges) { + if (!incomingCount.has(edge.source) || !incomingCount.has(edge.target)) continue; + adjacency.get(edge.source)!.push(edge.target); + incomingCount.set(edge.target, (incomingCount.get(edge.target) ?? 0) + 1); + } + + // Find entry points: nodes with 0 incoming edges + const entryPoints = codeNodes.filter((n) => (incomingCount.get(n.id) ?? 0) === 0); + + // Topological sort (Kahn's algorithm) + const sorted: GraphNode[] = []; + const queue = [...entryPoints]; + const visited = new Set(); + + while (queue.length > 0) { + const node = queue.shift()!; + if (visited.has(node.id)) continue; + visited.add(node.id); + sorted.push(node); + + for (const targetId of adjacency.get(node.id) ?? []) { + const count = (incomingCount.get(targetId) ?? 1) - 1; + incomingCount.set(targetId, count); + const targetNode = codeNodes.find((n) => n.id === targetId); + if (targetNode && count <= 0 && !visited.has(targetId)) { + queue.push(targetNode); + } + } + } + + // Add any unvisited nodes (cycles or disconnected) + for (const node of codeNodes) { + if (!visited.has(node.id)) { + sorted.push(node); + } + } + + // Group sorted nodes into tour steps + const steps: TourStep[] = []; + + if (layers.length > 0) { + // Group by layer, in topological order of first appearance + const layerOrder: string[] = []; + const layerNodes = new Map(); + const nodeToLayer = new Map(); + + for (const layer of layers) { + layerNodes.set(layer.id, []); + for (const nid of layer.nodeIds) { + nodeToLayer.set(nid, layer.id); + } + } + + // Determine layer order based on topological sort + for (const node of sorted) { + const lid = nodeToLayer.get(node.id); + if (lid) { + if (!layerOrder.includes(lid)) layerOrder.push(lid); + layerNodes.get(lid)!.push(node.id); + } + } + + // Unlayered nodes + const unlayered = sorted.filter((n) => !nodeToLayer.has(n.id)).map((n) => n.id); + + for (const lid of layerOrder) { + const layer = layers.find((l) => l.id === lid)!; + const nids = layerNodes.get(lid) ?? []; + if (nids.length === 0) continue; + + steps.push({ + order: steps.length + 1, + title: layer.name, + description: `${layer.description}. This layer contains: ${nids.map((id) => nodes.find((n) => n.id === id)?.name ?? id).join(", ")}.`, + nodeIds: nids, + }); + } + + if (unlayered.length > 0) { + steps.push({ + order: steps.length + 1, + title: "Supporting Components", + description: `Additional components that support the main architecture: ${unlayered.map((id) => nodes.find((n) => n.id === id)?.name ?? id).join(", ")}.`, + nodeIds: unlayered, + }); + } + } else { + // No layers: group by depth/batch (up to 3 nodes per step) + const batchSize = 3; + for (let i = 0; i < sorted.length; i += batchSize) { + const batch = sorted.slice(i, i + batchSize); + const names = batch.map((n) => n.name).join(", "); + steps.push({ + order: steps.length + 1, + title: i === 0 ? "Entry Points" : `Components: ${names}`, + description: batch.map((n) => `**${n.name}** — ${n.summary}`).join("\n\n"), + nodeIds: batch.map((n) => n.id), + }); + } + } + + // Add concept nodes as a final explanatory step + if (conceptNodes.length > 0) { + steps.push({ + order: steps.length + 1, + title: "Key Concepts", + description: conceptNodes + .map((n) => `**${n.name}** — ${n.summary}`) + .join("\n\n"), + nodeIds: conceptNodes.map((n) => n.id), + }); + } + + return steps; +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd packages/core && pnpm test -- --reporter verbose src/__tests__/tour-generator.test.ts +``` + +Expected: All tests PASS + +**Step 5: Add exports to index.ts** + +Add to `packages/core/src/index.ts`: +```typescript +export { + buildTourGenerationPrompt, + parseTourGenerationResponse, + generateHeuristicTour, +} from "./analyzer/tour-generator.js"; +``` + +**Step 6: Verify build** + +```bash +cd packages/core && pnpm build +``` + +**Step 7: Commit** + +```bash +git add packages/core/src/analyzer/tour-generator.ts packages/core/src/__tests__/tour-generator.test.ts packages/core/src/index.ts +git commit -m "feat(core): add tour generation engine with heuristic and LLM strategies" +``` + +--- + +## Task 2: LearnPanel Component + Tour Store State (Dashboard) + +**Files:** +- Create: `packages/dashboard/src/components/LearnPanel.tsx` +- Modify: `packages/dashboard/src/store.ts` (add tour state + actions) +- Modify: `packages/dashboard/src/App.tsx` (replace bottom-right NodeInfo with tabbed panel) + +**Context:** The dashboard currently has a 4-panel layout: GraphView (top-left), CodeViewer (top-right), ChatPanel (bottom-left), NodeInfo (bottom-right). This task adds tour state to the Zustand store and creates a LearnPanel component. The bottom-right panel becomes a tabbed view switching between NodeInfo and LearnPanel. The LearnPanel shows the tour step list and current step content. + +**Step 1: Add tour state to the Zustand store** + +In `packages/dashboard/src/store.ts`, add to the `DashboardStore` interface: + +```typescript +// Add these fields to the interface +tourActive: boolean; +currentTourStep: number; +tourHighlightedNodeIds: string[]; + +// Add these actions +startTour: () => void; +stopTour: () => void; +setTourStep: (step: number) => void; +nextTourStep: () => void; +prevTourStep: () => void; +``` + +Add to the store implementation: + +```typescript +tourActive: false, +currentTourStep: 0, +tourHighlightedNodeIds: [], + +startTour: () => { + const graph = get().graph; + if (!graph || graph.tour.length === 0) return; + const firstStep = graph.tour[0]; + set({ + tourActive: true, + currentTourStep: 0, + tourHighlightedNodeIds: firstStep.nodeIds, + selectedNodeId: null, + }); +}, + +stopTour: () => set({ + tourActive: false, + currentTourStep: 0, + tourHighlightedNodeIds: [], +}), + +setTourStep: (step) => { + const graph = get().graph; + if (!graph || step < 0 || step >= graph.tour.length) return; + set({ + currentTourStep: step, + tourHighlightedNodeIds: graph.tour[step].nodeIds, + }); +}, + +nextTourStep: () => { + const { graph, currentTourStep } = get(); + if (!graph) return; + const next = currentTourStep + 1; + if (next < graph.tour.length) { + set({ + currentTourStep: next, + tourHighlightedNodeIds: graph.tour[next].nodeIds, + }); + } +}, + +prevTourStep: () => { + const { graph, currentTourStep } = get(); + if (!graph) return; + const prev = currentTourStep - 1; + if (prev >= 0) { + set({ + currentTourStep: prev, + tourHighlightedNodeIds: graph.tour[prev].nodeIds, + }); + } +}, +``` + +**Step 2: Create the LearnPanel component** + +```tsx +// packages/dashboard/src/components/LearnPanel.tsx +import ReactMarkdown from "react-markdown"; +import { useDashboardStore } from "../store"; + +export default function LearnPanel() { + const graph = useDashboardStore((s) => s.graph); + const tourActive = useDashboardStore((s) => s.tourActive); + const currentTourStep = useDashboardStore((s) => s.currentTourStep); + const startTour = useDashboardStore((s) => s.startTour); + const stopTour = useDashboardStore((s) => s.stopTour); + const setTourStep = useDashboardStore((s) => s.setTourStep); + const nextTourStep = useDashboardStore((s) => s.nextTourStep); + const prevTourStep = useDashboardStore((s) => s.prevTourStep); + + const tourSteps = graph?.tour ?? []; + + if (tourSteps.length === 0) { + return ( +
+

No tour available for this project

+
+ ); + } + + if (!tourActive) { + return ( +
+
+

Project Tour

+

+ {tourSteps.length} steps to understand this codebase +

+

+ Follow a guided walkthrough of the project architecture +

+
+ + {/* Step list preview */} +
+ {tourSteps.map((step, i) => ( +
+ {step.order}. + {step.title} +
+ ))} +
+
+ ); + } + + const step = tourSteps[currentTourStep]; + const isFirst = currentTourStep === 0; + const isLast = currentTourStep === tourSteps.length - 1; + + return ( +
+ {/* Header with progress */} +
+

+ Tour +

+
+ + {currentTourStep + 1} / {tourSteps.length} + + +
+
+ + {/* Progress bar */} +
+
+
+ + {/* Step content */} +
+

{step.title}

+ +
+

{children}

, + strong: ({ children }) => {children}, + code: ({ children }) => ( + {children} + ), + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + }} + > + {step.description} +
+
+ + {/* Language lesson */} + {step.languageLesson && ( +
+

+ Language Concept +

+

+ {step.languageLesson} +

+
+ )} + + {/* Referenced nodes */} +
+

+ Referenced Components +

+
+ {step.nodeIds.map((nodeId) => { + const node = graph?.nodes.find((n) => n.id === nodeId); + return ( + + {node?.name ?? nodeId} + + ); + })} +
+
+
+ + {/* Step navigation */} +
+ {/* Step dots */} +
+ {tourSteps.map((_, i) => ( +
+ + {/* Prev/Next buttons */} +
+ + +
+
+
+ ); +} +``` + +**Step 3: Add tabbed bottom-right panel to App.tsx** + +Replace the bottom-right panel in `packages/dashboard/src/App.tsx`. Add import for `LearnPanel` and a `useState` for the active tab. The bottom-right `
` becomes: + +```tsx +import LearnPanel from "./components/LearnPanel"; +// ... in App component, add state: +const hasTour = (graph?.tour ?? []).length > 0; + +// Replace the bottom-right panel div: +{/* Bottom-right: Node Info / Learn Panel */} +
+ {hasTour && ( +
+ + +
+ )} +
+ {useDashboardStore.getState().tourActive ? : } +
+
+``` + +Note: The implementer should use the Zustand store selectors properly with `useDashboardStore((s) => s.tourActive)` instead of `getState()` — the above is a sketch. The tab state should be reactive. + +**Step 4: Verify dashboard compiles and renders** + +```bash +cd packages/dashboard && pnpm build +``` + +**Step 5: Commit** + +```bash +git add packages/dashboard/src/components/LearnPanel.tsx packages/dashboard/src/store.ts packages/dashboard/src/App.tsx +git commit -m "feat(dashboard): add LearnPanel component with tour state management" +``` + +--- + +## Task 3: Tour Player — Graph Highlighting + Node Focus + +**Files:** +- Modify: `packages/dashboard/src/components/GraphView.tsx` (highlight tour nodes) +- Modify: `packages/dashboard/src/components/CustomNode.tsx` (tour highlight style) + +**Context:** When a tour is active, the GraphView must visually distinguish the nodes referenced by the current tour step. This task adds a `isTourHighlighted` prop to CustomNode and wires it through GraphView using the `tourHighlightedNodeIds` from the store. Tour-highlighted nodes get a distinct pulsing blue ring (different from search highlights which are yellow). + +**Step 1: Add isTourHighlighted to CustomNode data** + +In `packages/dashboard/src/components/CustomNode.tsx`, add to `CustomNodeData`: + +```typescript +export interface CustomNodeData extends Record { + label: string; + nodeType: string; + summary: string; + complexity: string; + isHighlighted: boolean; + searchScore?: number; + isSelected: boolean; + isTourHighlighted: boolean; // NEW +} +``` + +Add tour highlight ring logic (takes priority over search highlight but not selection): + +```typescript +let ringClass = ""; +if (data.isSelected) { + ringClass = "ring-2 ring-white"; +} else if (data.isTourHighlighted) { + ringClass = "ring-2 ring-blue-400 animate-pulse"; +} else if (data.isHighlighted) { + const score = data.searchScore ?? 1; + if (score <= 0.1) { + ringClass = "ring-2 ring-yellow-300"; + } else if (score <= 0.3) { + ringClass = "ring-2 ring-yellow-400"; + } else { + ringClass = "ring-2 ring-yellow-500/60"; + } +} +``` + +**Step 2: Pass tourHighlightedNodeIds through GraphView** + +In `packages/dashboard/src/components/GraphView.tsx`, add the store selector: + +```typescript +const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds); +``` + +Add `tourHighlightedNodeIds` to the `useMemo` dependency array. In the `flowNodes` mapping, add: + +```typescript +isTourHighlighted: tourHighlightedNodeIds.includes(node.id), +``` + +**Step 3: Verify dashboard compiles** + +```bash +cd packages/dashboard && pnpm build +``` + +**Step 4: Commit** + +```bash +git add packages/dashboard/src/components/GraphView.tsx packages/dashboard/src/components/CustomNode.tsx +git commit -m "feat(dashboard): highlight tour-referenced nodes in graph view" +``` + +--- + +## Task 4: Contextual Node Explanation (Dashboard) + +**Files:** +- Modify: `packages/dashboard/src/store.ts` (add explanation state + action) +- Modify: `packages/dashboard/src/components/NodeInfo.tsx` (add Explain button + display) + +**Context:** Users should be able to click "Explain" on any node to get a detailed plain-English explanation generated by Claude. This reuses the same Anthropic SDK pattern from the ChatPanel but targets a single node. The explanation includes what the node does, why it exists, how it connects to the rest of the project, and any notable patterns. The explanation is cached per node ID to avoid re-calling the API. + +**Step 1: Add explanation state to the store** + +In `packages/dashboard/src/store.ts`, add to the interface: + +```typescript +nodeExplanation: string | null; +nodeExplanationLoading: boolean; +nodeExplanationCache: Record; +explainNode: (nodeId: string) => Promise; +``` + +Add to the implementation: + +```typescript +nodeExplanation: null, +nodeExplanationLoading: false, +nodeExplanationCache: {}, + +explainNode: async (nodeId) => { + const { apiKey, graph, nodeExplanationCache } = get(); + if (!apiKey || !graph) return; + + // Check cache first + if (nodeExplanationCache[nodeId]) { + set({ nodeExplanation: nodeExplanationCache[nodeId] }); + return; + } + + const node = graph.nodes.find((n) => n.id === nodeId); + if (!node) return; + + set({ nodeExplanationLoading: true, nodeExplanation: null }); + + try { + const connections = graph.edges.filter( + (e) => e.source === nodeId || e.target === nodeId, + ); + const connDetails = connections + .map((e) => { + const isSource = e.source === nodeId; + const otherId = isSource ? e.target : e.source; + const otherNode = graph.nodes.find((n) => n.id === otherId); + return `${isSource ? "->" : "<-"} [${e.type}] ${otherNode?.name ?? otherId}`; + }) + .join("\n"); + + const layer = graph.layers.find((l) => l.nodeIds.includes(nodeId)); + + const prompt = [ + `Explain the following code component in plain English. Be thorough but accessible.`, + ``, + `**Component:** ${node.name}`, + `**Type:** ${node.type}`, + `**File:** ${node.filePath ?? "N/A"}`, + `**Summary:** ${node.summary}`, + `**Complexity:** ${node.complexity}`, + `**Tags:** ${node.tags.join(", ") || "none"}`, + layer ? `**Layer:** ${layer.name} — ${layer.description}` : "", + ``, + `**Connections:**`, + connDetails || " none", + ``, + `Explain:`, + `1. What this component does and WHY it exists`, + `2. How it fits into the larger architecture`, + `3. Key relationships with other components`, + `4. Any patterns or concepts worth understanding`, + ``, + `Keep the explanation concise (2-4 paragraphs). Use markdown formatting.`, + ].join("\n"); + + const client = new Anthropic({ apiKey, dangerouslyAllowBrowser: true }); + const response = await client.messages.create({ + model: "claude-sonnet-4-20250514", + max_tokens: 512, + messages: [{ role: "user", content: prompt }], + }); + + const text = response.content[0].type === "text" + ? response.content[0].text + : "Unable to generate explanation."; + + set((state) => ({ + nodeExplanation: text, + nodeExplanationLoading: false, + nodeExplanationCache: { ...state.nodeExplanationCache, [nodeId]: text }, + })); + } catch (err) { + set({ + nodeExplanation: `Error: ${err instanceof Error ? err.message : "Failed to generate explanation"}`, + nodeExplanationLoading: false, + }); + } +}, +``` + +Don't forget to add `import Anthropic from "@anthropic-ai/sdk"` if not already imported (it is in the current store.ts). + +Also add to `selectNode` action — clear explanation when switching nodes: + +```typescript +selectNode: (nodeId) => set({ selectedNodeId: nodeId, nodeExplanation: null }), +``` + +**Step 2: Add Explain button and display to NodeInfo** + +In `packages/dashboard/src/components/NodeInfo.tsx`, add store selectors and the UI: + +```typescript +const apiKey = useDashboardStore((s) => s.apiKey); +const nodeExplanation = useDashboardStore((s) => s.nodeExplanation); +const nodeExplanationLoading = useDashboardStore((s) => s.nodeExplanationLoading); +const explainNode = useDashboardStore((s) => s.explainNode); +``` + +Add after the summary paragraph, before tags: + +```tsx +{/* Explain button + explanation */} +{apiKey && ( +
+ {!nodeExplanation && !nodeExplanationLoading && ( + + )} + {nodeExplanationLoading && ( +
+ Generating explanation... +
+ )} + {nodeExplanation && ( +
+

{children}

, + strong: ({ children }) => {children}, + code: ({ children }) => ( + {children} + ), + }} + > + {nodeExplanation} +
+
+ )} +
+)} +``` + +Don't forget to add `import ReactMarkdown from "react-markdown"` to NodeInfo. + +**Step 3: Verify dashboard compiles** + +```bash +cd packages/dashboard && pnpm build +``` + +**Step 4: Commit** + +```bash +git add packages/dashboard/src/store.ts packages/dashboard/src/components/NodeInfo.tsx +git commit -m "feat(dashboard): add contextual node explanation with Claude API" +``` + +--- + +## Task 5: Language Lesson Prompt Builder (Core) + +**Files:** +- Create: `packages/core/src/analyzer/language-lesson.ts` +- Create: `packages/core/src/__tests__/language-lesson.test.ts` +- Modify: `packages/core/src/index.ts` (add exports) + +**Context:** The `languageNotes` field on `GraphNode` and `languageLesson` field on `TourStep` are designed for language-specific teaching. This task builds the LLM prompt templates that generate these lessons — explaining language concepts (async/await, generics, middleware patterns, decorators, etc.) in the context of the user's actual code. This is what makes "Learn Mode" unique: you learn Go/Rust/TypeScript concepts by seeing them explained in YOUR project. + +**Step 1: Write failing tests** + +```typescript +// packages/core/src/__tests__/language-lesson.test.ts +import { describe, it, expect } from "vitest"; +import { + buildLanguageLessonPrompt, + parseLanguageLessonResponse, + detectLanguageConcepts, +} from "../analyzer/language-lesson.js"; +import type { GraphNode, GraphEdge } from "../types.js"; + +const sampleNode: GraphNode = { + id: "func:auth:verifyToken", + type: "function", + name: "verifyToken", + filePath: "src/auth/verify.ts", + lineRange: [10, 35], + summary: "Verifies JWT tokens and extracts user payload using async/await", + tags: ["auth", "jwt", "async"], + complexity: "moderate", +}; + +const sampleEdges: GraphEdge[] = [ + { source: "func:auth:verifyToken", target: "file:src/config.ts", type: "reads_from", direction: "forward", weight: 0.6 }, + { source: "file:src/middleware.ts", target: "func:auth:verifyToken", type: "calls", direction: "forward", weight: 0.8 }, +]; + +describe("language-lesson", () => { + describe("buildLanguageLessonPrompt", () => { + it("includes the node name and summary", () => { + const prompt = buildLanguageLessonPrompt(sampleNode, sampleEdges, "typescript"); + expect(prompt).toContain("verifyToken"); + expect(prompt).toContain("JWT tokens"); + }); + + it("includes the target language", () => { + const prompt = buildLanguageLessonPrompt(sampleNode, sampleEdges, "typescript"); + expect(prompt).toContain("TypeScript"); + }); + + it("includes relationship context", () => { + const prompt = buildLanguageLessonPrompt(sampleNode, sampleEdges, "typescript"); + expect(prompt).toContain("reads_from"); + }); + + it("requests JSON output", () => { + const prompt = buildLanguageLessonPrompt(sampleNode, sampleEdges, "typescript"); + expect(prompt).toContain("JSON"); + }); + }); + + describe("parseLanguageLessonResponse", () => { + it("parses a valid response", () => { + const response = JSON.stringify({ + languageNotes: "This function uses async/await for non-blocking JWT verification.", + concepts: [ + { name: "async/await", explanation: "TypeScript's way of handling asynchronous operations." }, + ], + }); + const result = parseLanguageLessonResponse(response); + expect(result.languageNotes).toContain("async/await"); + expect(result.concepts).toHaveLength(1); + }); + + it("extracts JSON from code blocks", () => { + const response = "```json\n" + JSON.stringify({ + languageNotes: "Uses generics.", + concepts: [], + }) + "\n```"; + const result = parseLanguageLessonResponse(response); + expect(result.languageNotes).toContain("generics"); + }); + + it("returns empty result for invalid response", () => { + const result = parseLanguageLessonResponse("not json"); + expect(result.languageNotes).toBe(""); + expect(result.concepts).toEqual([]); + }); + }); + + describe("detectLanguageConcepts", () => { + it("detects async patterns from tags", () => { + const concepts = detectLanguageConcepts(sampleNode, "typescript"); + expect(concepts).toContain("async/await"); + }); + + it("detects middleware pattern", () => { + const node: GraphNode = { + ...sampleNode, + tags: ["middleware", "express"], + summary: "Express middleware that validates requests", + }; + const concepts = detectLanguageConcepts(node, "typescript"); + expect(concepts).toContain("middleware pattern"); + }); + + it("returns empty for nodes with no detectable concepts", () => { + const node: GraphNode = { + ...sampleNode, + tags: ["config"], + summary: "Simple configuration file", + }; + const concepts = detectLanguageConcepts(node, "typescript"); + expect(concepts.length).toBeLessThanOrEqual(1); + }); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd packages/core && pnpm test -- --reporter verbose src/__tests__/language-lesson.test.ts +``` + +**Step 3: Implement language-lesson.ts** + +```typescript +// packages/core/src/analyzer/language-lesson.ts +import type { GraphNode, GraphEdge } from "../types.js"; + +export interface LanguageLessonResult { + languageNotes: string; + concepts: Array<{ name: string; explanation: string }>; +} + +// Concept detection patterns — maps keywords/tags to concept names +const CONCEPT_PATTERNS: Record = { + "async/await": ["async", "await", "promise", "asynchronous"], + "middleware pattern": ["middleware", "interceptor", "pipe"], + "generics": ["generic", "type parameter", "template"], + "decorators": ["decorator", "@", "annotation"], + "dependency injection": ["inject", "provider", "container", "di"], + "observer pattern": ["subscribe", "publish", "event", "observable", "listener"], + "singleton": ["singleton", "instance", "shared client"], + "type guards": ["type guard", "is", "narrowing", "discriminated union"], + "higher-order functions": ["callback", "factory", "higher-order", "closure"], + "error handling": ["try/catch", "error boundary", "exception", "Result type"], + "streams": ["stream", "pipe", "transform", "readable", "writable"], + "concurrency": ["goroutine", "channel", "thread", "worker", "mutex"], +}; + +/** + * Detect language concepts likely used in a node based on tags and summary. + */ +export function detectLanguageConcepts(node: GraphNode, language: string): string[] { + const text = [ + ...node.tags, + node.summary.toLowerCase(), + node.languageNotes?.toLowerCase() ?? "", + ].join(" "); + + const detected: string[] = []; + for (const [concept, keywords] of Object.entries(CONCEPT_PATTERNS)) { + if (keywords.some((kw) => text.includes(kw))) { + detected.push(concept); + } + } + + return detected; +} + +/** + * Build an LLM prompt to generate language-specific lessons for a node. + */ +export function buildLanguageLessonPrompt( + node: GraphNode, + edges: GraphEdge[], + language: string, +): string { + const capitalLang = language.charAt(0).toUpperCase() + language.slice(1); + const detectedConcepts = detectLanguageConcepts(node, language); + + const edgeContext = edges + .map((e) => { + const dir = e.source === node.id ? "->" : "<-"; + const other = e.source === node.id ? e.target : e.source; + return ` ${dir} [${e.type}] ${other}`; + }) + .join("\n"); + + return [ + `You are a programming teacher. Explain the ${capitalLang} concepts used in this code component.`, + `The reader may not know ${capitalLang} — explain concepts as if teaching them for the first time,`, + `but in the context of THIS specific code, not abstractly.`, + ``, + `## Component`, + `- Name: ${node.name}`, + `- Type: ${node.type}`, + `- File: ${node.filePath ?? "N/A"}`, + `- Summary: ${node.summary}`, + `- Tags: ${node.tags.join(", ")}`, + ``, + `## Relationships`, + edgeContext || " none", + ``, + detectedConcepts.length > 0 + ? `## Detected Concepts (explain these)\n${detectedConcepts.map((c) => `- ${c}`).join("\n")}` + : `## Note\nIdentify and explain any ${capitalLang}-specific patterns used in this component.`, + ``, + `Return JSON:`, + `\`\`\`json`, + `{`, + ` "languageNotes": "2-3 sentence summary of language concepts used here",`, + ` "concepts": [`, + ` { "name": "concept name", "explanation": "1-2 sentence explanation in context of this code" }`, + ` ]`, + `}`, + `\`\`\``, + ].join("\n"); +} + +/** + * Parse the LLM response into a LanguageLessonResult. + */ +export function parseLanguageLessonResponse(response: string): LanguageLessonResult { + let json = response; + const codeBlockMatch = response.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/); + if (codeBlockMatch) { + json = codeBlockMatch[1]; + } + + try { + const parsed = JSON.parse(json); + return { + languageNotes: typeof parsed.languageNotes === "string" ? parsed.languageNotes : "", + concepts: Array.isArray(parsed.concepts) + ? parsed.concepts.filter( + (c: unknown): c is { name: string; explanation: string } => + typeof c === "object" && + c !== null && + typeof (c as Record).name === "string" && + typeof (c as Record).explanation === "string", + ) + : [], + }; + } catch { + return { languageNotes: "", concepts: [] }; + } +} +``` + +**Step 4: Run tests** + +```bash +cd packages/core && pnpm test -- --reporter verbose src/__tests__/language-lesson.test.ts +``` + +**Step 5: Add exports to index.ts** + +```typescript +export { + buildLanguageLessonPrompt, + parseLanguageLessonResponse, + detectLanguageConcepts, + type LanguageLessonResult, +} from "./analyzer/language-lesson.js"; +``` + +**Step 6: Build + full test suite** + +```bash +cd packages/core && pnpm build && pnpm test +``` + +**Step 7: Commit** + +```bash +git add packages/core/src/analyzer/language-lesson.ts packages/core/src/__tests__/language-lesson.test.ts packages/core/src/index.ts +git commit -m "feat(core): add language lesson prompt builder and concept detector" +``` + +--- + +## Task 6: Enhanced Language Lesson Display (Dashboard) + +**Files:** +- Modify: `packages/dashboard/src/components/NodeInfo.tsx` (enhanced languageNotes display) +- Modify: `packages/dashboard/src/components/LearnPanel.tsx` (rich language lesson in tour) + +**Context:** The `languageNotes` field on nodes and `languageLesson` on tour steps already exist in the data. NodeInfo currently shows `languageNotes` as plain text in a blue box. This task upgrades both displays: NodeInfo gets a collapsible "Language Concepts" section with detected concept pills, and LearnPanel's language lesson section gets a more structured layout with concept cards. + +**Step 1: Enhance NodeInfo languageNotes display** + +Replace the existing `languageNotes` section in `packages/dashboard/src/components/NodeInfo.tsx` with: + +```tsx +{node.languageNotes && ( +
+ + {languageExpanded && ( +
+

+ {node.languageNotes} +

+
+ )} +
+)} +``` + +Add `const [languageExpanded, setLanguageExpanded] = useState(true);` at the top of the component. + +**Step 2: Enhance LearnPanel language lesson display** + +The `languageLesson` section in LearnPanel is already created in Task 2. Make sure it matches this enhanced styling with an icon and visual distinction. No changes needed if Task 2 was implemented correctly. + +**Step 3: Verify dashboard compiles** + +```bash +cd packages/dashboard && pnpm build +``` + +**Step 4: Commit** + +```bash +git add packages/dashboard/src/components/NodeInfo.tsx packages/dashboard/src/components/LearnPanel.tsx +git commit -m "feat(dashboard): enhance language lesson display with collapsible sections" +``` + +--- + +## Task 7: Persona Mode System (Dashboard) + +**Files:** +- Create: `packages/dashboard/src/components/PersonaSelector.tsx` +- Modify: `packages/dashboard/src/store.ts` (add persona state) +- Modify: `packages/dashboard/src/App.tsx` (persona-adaptive layout) +- Modify: `packages/dashboard/src/components/GraphView.tsx` (filter nodes by persona) + +**Context:** The design doc specifies three persona modes that change what the dashboard shows. This is the largest task in Phase 3, as it affects the layout, node filtering, and panel visibility. The three modes are: + +1. **Non-technical** — Hide CodeViewer, show only concept + module nodes in graph, expand LearnPanel to full right side. For PMs, designers, stakeholders. +2. **Junior dev** — Full 4-panel layout with LearnPanel prominent (instead of NodeInfo). Show all nodes with complexity indicators. For developers learning the codebase. +3. **Experienced dev** — Full 4-panel layout with CodeViewer and ChatPanel prominent, NodeInfo instead of LearnPanel. For senior devs doing deep dives. + +**Step 1: Add persona state to the store** + +In `packages/dashboard/src/store.ts`: + +```typescript +// Add to interface +persona: "non-technical" | "junior" | "experienced"; +setPersona: (persona: "non-technical" | "junior" | "experienced") => void; + +// Add to implementation +persona: "junior", // sensible default +setPersona: (persona) => set({ persona }), +``` + +**Step 2: Create PersonaSelector component** + +```tsx +// packages/dashboard/src/components/PersonaSelector.tsx +import { useDashboardStore } from "../store"; + +const personas = [ + { + id: "non-technical" as const, + label: "Overview", + description: "High-level architecture view", + }, + { + id: "junior" as const, + label: "Learn", + description: "Full dashboard with guided learning", + }, + { + id: "experienced" as const, + label: "Deep Dive", + description: "Code-focused with chat", + }, +]; + +export default function PersonaSelector() { + const persona = useDashboardStore((s) => s.persona); + const setPersona = useDashboardStore((s) => s.setPersona); + + return ( +
+ {personas.map((p) => ( + + ))} +
+ ); +} +``` + +**Step 3: Add PersonaSelector to App.tsx header** + +Import `PersonaSelector` and add it to the header bar, between the project info and search. + +**Step 4: Make App.tsx layout persona-adaptive** + +The 4-panel grid changes based on persona: + +```tsx +const persona = useDashboardStore((s) => s.persona); +const tourActive = useDashboardStore((s) => s.tourActive); + +// Non-technical: 2-column layout (graph + learn panel, no code viewer) +// Junior: 4-panel with LearnPanel in bottom-right +// Experienced: 4-panel with NodeInfo in bottom-right + +{persona === "non-technical" ? ( +
+
+ +
+
+
+ +
+
+ +
+
+
+) : ( +
+
+ +
+
+ +
+
+ +
+
+ {persona === "junior" || tourActive ? : } +
+
+)} +``` + +**Step 5: Filter graph nodes by persona in GraphView** + +In `packages/dashboard/src/components/GraphView.tsx`, add persona-based node filtering: + +```typescript +const persona = useDashboardStore((s) => s.persona); + +// Inside the useMemo, after creating flowNodes: +const filteredGraphNodes = persona === "non-technical" + ? graph.nodes.filter((n) => n.type === "concept" || n.type === "module" || n.type === "file") + : graph.nodes; + +// Use filteredGraphNodes instead of graph.nodes for building flowNodes +``` + +For non-technical mode, only show concept, module, and file-level nodes (skip function/class for simplicity). Also filter edges to only include those where both source and target are in the filtered set. + +**Step 6: Verify dashboard compiles** + +```bash +cd packages/dashboard && pnpm build +``` + +**Step 7: Commit** + +```bash +git add packages/dashboard/src/components/PersonaSelector.tsx packages/dashboard/src/store.ts packages/dashboard/src/App.tsx packages/dashboard/src/components/GraphView.tsx +git commit -m "feat(dashboard): add persona mode system (Overview / Learn / Deep Dive)" +``` + +--- + +## Verification Checklist + +After all tasks are complete: + +1. `cd packages/core && pnpm build && pnpm test` — all tests pass (existing 92 + new ~20) +2. `cd packages/dashboard && pnpm build` — compiles without errors +3. `pnpm dev:dashboard` — tour works end-to-end with sample data: + - Start Tour button appears in bottom-right + - Steps navigate with Prev/Next + - Graph nodes highlight per step + - Language lessons display in tour steps +4. Persona selector in header switches layouts correctly: + - Non-technical: 2-column, no CodeViewer, only high-level nodes + - Junior/Learn: 4-panel with LearnPanel + - Experienced/Deep Dive: 4-panel with NodeInfo +5. "Explain This" button on NodeInfo generates contextual explanation via Claude API +6. All existing Phase 1 + Phase 2 features still work (search, chat, layers, dagre layout) diff --git a/docs/plans/2026-03-14-phase4-implementation.md b/docs/plans/2026-03-14-phase4-implementation.md new file mode 100644 index 0000000..5c0837c --- /dev/null +++ b/docs/plans/2026-03-14-phase4-implementation.md @@ -0,0 +1,1872 @@ +# Understand Anything — Phase 4 (Advanced) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add the "Advanced" layer — three new skill commands (`/understand-diff`, `/understand-explain`, `/understand-onboard`), a community plugin system, and optional embedding-based semantic search. + +**Architecture:** Extends the skill package with three new Claude Code skill definitions + supporting core utilities. Adds a plugin registry to core for community extensibility. Optionally adds embedding-based search as an upgrade path from the existing fuse.js search. + +**Tech Stack:** No new dependencies for Tasks 1-5. Task 6-7 (embedding search) adds a vector similarity library or uses raw cosine calculation. + +--- + +## Dependency Graph + +``` +Task 1 (understand-diff) ─── (independent) +Task 2 (understand-explain) ─── (independent) +Task 3 (understand-onboard) ─── (independent) +Task 4 (Plugin Registry Core) ───→ Task 5 (Plugin CLI Integration) +Task 6 (Embedding Search Core) ───→ Task 7 (Embedding Dashboard) +``` + +Tasks 1, 2, 3, 4, 6 are fully independent and can be implemented in any order. + +--- + +## Task 1: /understand-diff Skill — PR/Diff Analysis + +**Files:** +- Create: `packages/skill/src/diff-analyzer.ts` +- Create: `packages/skill/src/__tests__/diff-analyzer.test.ts` +- Create: `packages/skill/.claude/skills/understand-diff.md` +- Modify: `packages/skill/src/index.ts` (add exports) + +**Context:** The `/understand-diff` skill analyzes the current git diff (or PR) against the knowledge graph. It maps changed files to affected nodes, identifies impacted relationships and layers, and generates a structured analysis of changes, affected areas, and risks. This is designed to run inside Claude Code where the LLM can read the analysis and explain it to the user. + +**Step 1: Write failing tests** + +```typescript +// packages/skill/src/__tests__/diff-analyzer.test.ts +import { describe, it, expect } from "vitest"; +import { buildDiffContext, formatDiffAnalysis } from "../diff-analyzer.js"; +import type { KnowledgeGraph } from "@understand-anything/core"; + +const sampleGraph: KnowledgeGraph = { + version: "1.0.0", + project: { + name: "test-project", + languages: ["typescript"], + frameworks: ["express"], + description: "A test project", + analyzedAt: "2026-03-14T00:00:00Z", + gitCommitHash: "abc123", + }, + nodes: [ + { id: "file:src/index.ts", type: "file", name: "index.ts", filePath: "src/index.ts", summary: "Entry point", tags: ["entry"], complexity: "simple" }, + { id: "file:src/routes.ts", type: "file", name: "routes.ts", filePath: "src/routes.ts", summary: "Routes", tags: ["routes"], complexity: "moderate" }, + { id: "file:src/service.ts", type: "file", name: "service.ts", filePath: "src/service.ts", summary: "Service", tags: ["service"], complexity: "complex" }, + { id: "func:src/service.ts:process", type: "function", name: "process", filePath: "src/service.ts", lineRange: [10, 30], summary: "Process function", tags: ["core"], complexity: "complex" }, + { id: "file:src/db.ts", type: "file", name: "db.ts", filePath: "src/db.ts", summary: "Database", tags: ["db"], complexity: "simple" }, + ], + edges: [ + { source: "file:src/index.ts", target: "file:src/routes.ts", type: "imports", direction: "forward", weight: 0.9 }, + { source: "file:src/routes.ts", target: "file:src/service.ts", type: "calls", direction: "forward", weight: 0.8 }, + { source: "file:src/service.ts", target: "func:src/service.ts:process", type: "contains", direction: "forward", weight: 1.0 }, + { source: "file:src/service.ts", target: "file:src/db.ts", type: "reads_from", direction: "forward", weight: 0.7 }, + ], + layers: [ + { id: "layer:api", name: "API Layer", description: "HTTP routes", nodeIds: ["file:src/index.ts", "file:src/routes.ts"] }, + { id: "layer:service", name: "Service Layer", description: "Business logic", nodeIds: ["file:src/service.ts", "func:src/service.ts:process"] }, + { id: "layer:data", name: "Data Layer", description: "Database", nodeIds: ["file:src/db.ts"] }, + ], + tour: [], +}; + +describe("diff-analyzer", () => { + describe("buildDiffContext", () => { + it("identifies directly changed nodes", () => { + const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]); + expect(ctx.changedNodes.map((n) => n.id)).toContain("file:src/service.ts"); + }); + + it("identifies child nodes of changed files", () => { + const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]); + expect(ctx.changedNodes.map((n) => n.id)).toContain("func:src/service.ts:process"); + }); + + it("identifies affected nodes via edges (1-hop)", () => { + const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]); + // routes.ts calls service.ts, so it's affected + expect(ctx.affectedNodes.map((n) => n.id)).toContain("file:src/routes.ts"); + // db.ts is read by service.ts, so it's affected + expect(ctx.affectedNodes.map((n) => n.id)).toContain("file:src/db.ts"); + }); + + it("identifies affected layers", () => { + const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]); + expect(ctx.affectedLayers.map((l) => l.name)).toContain("Service Layer"); + }); + + it("identifies impacted edges", () => { + const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]); + expect(ctx.impactedEdges.length).toBeGreaterThan(0); + }); + + it("handles files not in the graph gracefully", () => { + const ctx = buildDiffContext(sampleGraph, ["src/unknown.ts"]); + expect(ctx.changedNodes).toHaveLength(0); + expect(ctx.unmappedFiles).toContain("src/unknown.ts"); + }); + + it("handles empty diff", () => { + const ctx = buildDiffContext(sampleGraph, []); + expect(ctx.changedNodes).toHaveLength(0); + expect(ctx.affectedNodes).toHaveLength(0); + }); + + it("de-duplicates affected nodes (not in changed set)", () => { + const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]); + const changedIds = new Set(ctx.changedNodes.map((n) => n.id)); + for (const affected of ctx.affectedNodes) { + expect(changedIds.has(affected.id)).toBe(false); + } + }); + }); + + describe("formatDiffAnalysis", () => { + it("produces structured markdown", () => { + const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]); + const analysis = formatDiffAnalysis(ctx); + expect(analysis).toContain("## Changed Components"); + expect(analysis).toContain("## Affected Components"); + expect(analysis).toContain("## Affected Layers"); + }); + + it("includes risk assessment section", () => { + const ctx = buildDiffContext(sampleGraph, ["src/service.ts"]); + const analysis = formatDiffAnalysis(ctx); + expect(analysis).toContain("## Risk Assessment"); + }); + + it("lists unmapped files when present", () => { + const ctx = buildDiffContext(sampleGraph, ["src/unknown.ts"]); + const analysis = formatDiffAnalysis(ctx); + expect(analysis).toContain("src/unknown.ts"); + }); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd packages/skill && pnpm test -- --reporter verbose src/__tests__/diff-analyzer.test.ts +``` + +**Step 3: Implement diff-analyzer.ts** + +```typescript +// packages/skill/src/diff-analyzer.ts +import type { KnowledgeGraph, GraphNode, GraphEdge, Layer } from "@understand-anything/core"; + +export interface DiffContext { + projectName: string; + changedFiles: string[]; + changedNodes: GraphNode[]; + affectedNodes: GraphNode[]; + impactedEdges: GraphEdge[]; + affectedLayers: Layer[]; + unmappedFiles: string[]; +} + +/** + * Map a list of changed file paths to knowledge graph nodes and + * identify the ripple effect (affected nodes, layers, edges). + */ +export function buildDiffContext( + graph: KnowledgeGraph, + changedFiles: string[], +): DiffContext { + const { nodes, edges, layers } = graph; + + // Map files to directly changed nodes + const changedNodeIds = new Set(); + const unmappedFiles: string[] = []; + + for (const file of changedFiles) { + let mapped = false; + for (const node of nodes) { + if (node.filePath === file) { + changedNodeIds.add(node.id); + mapped = true; + } + } + if (!mapped) { + unmappedFiles.push(file); + } + } + + // Also include "contains" children of changed file nodes + for (const edge of edges) { + if (edge.type === "contains" && changedNodeIds.has(edge.source)) { + changedNodeIds.add(edge.target); + } + } + + const changedNodes = nodes.filter((n) => changedNodeIds.has(n.id)); + + // Find affected nodes: 1-hop neighbors of changed nodes (excluding already changed) + const affectedNodeIds = new Set(); + const impactedEdges: GraphEdge[] = []; + + for (const edge of edges) { + const sourceChanged = changedNodeIds.has(edge.source); + const targetChanged = changedNodeIds.has(edge.target); + + if (sourceChanged || targetChanged) { + impactedEdges.push(edge); + if (sourceChanged && !changedNodeIds.has(edge.target)) { + affectedNodeIds.add(edge.target); + } + if (targetChanged && !changedNodeIds.has(edge.source)) { + affectedNodeIds.add(edge.source); + } + } + } + + const affectedNodes = nodes.filter((n) => affectedNodeIds.has(n.id)); + + // Find affected layers: any layer containing a changed or affected node + const allImpactedIds = new Set([...changedNodeIds, ...affectedNodeIds]); + const affectedLayers = layers.filter((layer) => + layer.nodeIds.some((id) => allImpactedIds.has(id)), + ); + + return { + projectName: graph.project.name, + changedFiles, + changedNodes, + affectedNodes, + impactedEdges, + affectedLayers, + unmappedFiles, + }; +} + +/** + * Format the diff analysis as structured markdown for LLM or human consumption. + */ +export function formatDiffAnalysis(ctx: DiffContext): string { + const lines: string[] = []; + + lines.push(`# Diff Analysis: ${ctx.projectName}`); + lines.push(""); + + // Changed components + lines.push("## Changed Components"); + lines.push(""); + if (ctx.changedNodes.length === 0) { + lines.push("No mapped components found for changed files."); + } else { + for (const node of ctx.changedNodes) { + lines.push(`- **${node.name}** (${node.type}) — ${node.summary}`); + if (node.filePath) lines.push(` - File: \`${node.filePath}\``); + lines.push(` - Complexity: ${node.complexity}`); + } + } + lines.push(""); + + // Affected components (ripple effect) + lines.push("## Affected Components"); + lines.push(""); + if (ctx.affectedNodes.length === 0) { + lines.push("No downstream impact detected."); + } else { + lines.push("These components are connected to changed code and may need attention:"); + lines.push(""); + for (const node of ctx.affectedNodes) { + lines.push(`- **${node.name}** (${node.type}) — ${node.summary}`); + } + } + lines.push(""); + + // Affected layers + lines.push("## Affected Layers"); + lines.push(""); + if (ctx.affectedLayers.length === 0) { + lines.push("No layers affected."); + } else { + for (const layer of ctx.affectedLayers) { + lines.push(`- **${layer.name}**: ${layer.description}`); + } + } + lines.push(""); + + // Impacted relationships + if (ctx.impactedEdges.length > 0) { + lines.push("## Impacted Relationships"); + lines.push(""); + for (const edge of ctx.impactedEdges) { + lines.push(`- ${edge.source} --[${edge.type}]--> ${edge.target}`); + } + lines.push(""); + } + + // Unmapped files + if (ctx.unmappedFiles.length > 0) { + lines.push("## Unmapped Files"); + lines.push(""); + lines.push("These changed files are not yet in the knowledge graph:"); + lines.push(""); + for (const f of ctx.unmappedFiles) { + lines.push(`- \`${f}\``); + } + lines.push(""); + } + + // Risk assessment + lines.push("## Risk Assessment"); + lines.push(""); + const complexChanges = ctx.changedNodes.filter((n) => n.complexity === "complex"); + const crossLayerCount = new Set(ctx.affectedLayers.map((l) => l.id)).size; + + if (complexChanges.length > 0) { + lines.push(`- **High complexity**: ${complexChanges.length} complex component(s) changed: ${complexChanges.map((n) => n.name).join(", ")}`); + } + if (crossLayerCount > 1) { + lines.push(`- **Cross-layer impact**: Changes span ${crossLayerCount} architectural layers`); + } + if (ctx.affectedNodes.length > 5) { + lines.push(`- **Wide blast radius**: ${ctx.affectedNodes.length} components affected downstream`); + } + if (ctx.unmappedFiles.length > 0) { + lines.push(`- **New/unmapped files**: ${ctx.unmappedFiles.length} files not in the knowledge graph (may need re-analysis)`); + } + if (complexChanges.length === 0 && crossLayerCount <= 1 && ctx.affectedNodes.length <= 5 && ctx.unmappedFiles.length === 0) { + lines.push("- **Low risk**: Changes are localized with limited downstream impact."); + } + lines.push(""); + + return lines.join("\n"); +} +``` + +**Step 4: Run tests** + +```bash +cd packages/skill && pnpm test -- --reporter verbose src/__tests__/diff-analyzer.test.ts +``` + +**Step 5: Add exports to index.ts** + +```typescript +export { + buildDiffContext, + formatDiffAnalysis, + type DiffContext, +} from "./diff-analyzer.js"; +``` + +**Step 6: Create the skill definition** + +```markdown + +--- +name: understand-diff +description: Analyze current git diff or PR against the knowledge graph to identify changes, impact, and risks +--- + +# /understand-diff + +Analyze the current code changes against the knowledge graph at `.understand-anything/knowledge-graph.json`. + +## Instructions + +1. Read the knowledge graph file at `.understand-anything/knowledge-graph.json` in the current project root +2. If the file doesn't exist, tell the user to run `/understand` first +3. Get the current diff: + - If on a branch with uncommitted changes: `git diff --name-only` + - If on a feature branch: `git diff main...HEAD --name-only` (or the base branch) + - If the user specifies a PR number: get the diff from that PR +4. For each changed file, identify: + - Which nodes in the knowledge graph correspond to that file + - Which other nodes are connected (imports, calls, depends_on, etc.) + - Which architectural layers are affected +5. Provide a structured analysis: + - **Changed Components**: What was directly modified + - **Affected Components**: What might be impacted by the changes + - **Affected Layers**: Which architectural layers are touched + - **Risk Assessment**: Complexity, cross-layer impact, blast radius +6. Suggest what to review carefully and any potential issues +``` + +**Step 7: Build + test** + +```bash +cd packages/skill && pnpm build && pnpm test +``` + +**Step 8: Commit** + +```bash +git add packages/skill/src/diff-analyzer.ts packages/skill/src/__tests__/diff-analyzer.test.ts packages/skill/src/index.ts packages/skill/.claude/skills/understand-diff.md +git commit -m "feat(skill): add /understand-diff command for PR/diff analysis" +``` + +--- + +## Task 2: /understand-explain Skill — Deep-Dive on Files + +**Files:** +- Create: `packages/skill/src/explain-builder.ts` +- Create: `packages/skill/src/__tests__/explain-builder.test.ts` +- Create: `packages/skill/.claude/skills/understand-explain.md` +- Modify: `packages/skill/src/index.ts` (add exports) + +**Context:** The `/understand-explain ` skill provides a deep-dive explanation of a specific file or function. It gathers all nodes that belong to that file, their connections, layer membership, and constructs a comprehensive context for the LLM to explain the component. This differs from `/understand-chat` which answers any question — `/understand-explain` is focused on thorough explanation of a single component. + +**Step 1: Write failing tests** + +```typescript +// packages/skill/src/__tests__/explain-builder.test.ts +import { describe, it, expect } from "vitest"; +import { buildExplainContext, formatExplainPrompt } from "../explain-builder.js"; +import type { KnowledgeGraph } from "@understand-anything/core"; + +const sampleGraph: KnowledgeGraph = { + version: "1.0.0", + project: { + name: "test-project", + languages: ["typescript"], + frameworks: ["express"], + description: "A test project", + analyzedAt: "2026-03-14T00:00:00Z", + gitCommitHash: "abc123", + }, + nodes: [ + { id: "file:src/auth.ts", type: "file", name: "auth.ts", filePath: "src/auth.ts", summary: "Auth module", tags: ["auth"], complexity: "complex" }, + { id: "func:src/auth.ts:login", type: "function", name: "login", filePath: "src/auth.ts", lineRange: [10, 30], summary: "Login handler", tags: ["auth", "login"], complexity: "moderate" }, + { id: "func:src/auth.ts:verify", type: "function", name: "verify", filePath: "src/auth.ts", lineRange: [32, 50], summary: "Token verification", tags: ["auth", "jwt"], complexity: "moderate" }, + { id: "file:src/db.ts", type: "file", name: "db.ts", filePath: "src/db.ts", summary: "Database", tags: ["db"], complexity: "simple" }, + ], + edges: [ + { source: "file:src/auth.ts", target: "func:src/auth.ts:login", type: "contains", direction: "forward", weight: 1.0 }, + { source: "file:src/auth.ts", target: "func:src/auth.ts:verify", type: "contains", direction: "forward", weight: 1.0 }, + { source: "func:src/auth.ts:login", target: "file:src/db.ts", type: "reads_from", direction: "forward", weight: 0.8 }, + ], + layers: [ + { id: "layer:auth", name: "Auth Layer", description: "Authentication", nodeIds: ["file:src/auth.ts", "func:src/auth.ts:login", "func:src/auth.ts:verify"] }, + ], + tour: [], +}; + +describe("explain-builder", () => { + describe("buildExplainContext", () => { + it("finds the file node by path", () => { + const ctx = buildExplainContext(sampleGraph, "src/auth.ts"); + expect(ctx.targetNode?.id).toBe("file:src/auth.ts"); + }); + + it("includes child nodes (functions/classes in the file)", () => { + const ctx = buildExplainContext(sampleGraph, "src/auth.ts"); + expect(ctx.childNodes.map((n) => n.name)).toContain("login"); + expect(ctx.childNodes.map((n) => n.name)).toContain("verify"); + }); + + it("includes connected nodes", () => { + const ctx = buildExplainContext(sampleGraph, "src/auth.ts"); + const allIds = ctx.connectedNodes.map((n) => n.id); + expect(allIds).toContain("file:src/db.ts"); + }); + + it("includes the layer", () => { + const ctx = buildExplainContext(sampleGraph, "src/auth.ts"); + expect(ctx.layer?.name).toBe("Auth Layer"); + }); + + it("returns null targetNode for unknown paths", () => { + const ctx = buildExplainContext(sampleGraph, "src/unknown.ts"); + expect(ctx.targetNode).toBeNull(); + }); + + it("finds function nodes by partial path match", () => { + const ctx = buildExplainContext(sampleGraph, "src/auth.ts:login"); + expect(ctx.targetNode?.name).toBe("login"); + }); + }); + + describe("formatExplainPrompt", () => { + it("produces structured markdown for valid context", () => { + const ctx = buildExplainContext(sampleGraph, "src/auth.ts"); + const prompt = formatExplainPrompt(ctx); + expect(prompt).toContain("auth.ts"); + expect(prompt).toContain("login"); + expect(prompt).toContain("Auth Layer"); + }); + + it("produces helpful message for unknown path", () => { + const ctx = buildExplainContext(sampleGraph, "src/unknown.ts"); + const prompt = formatExplainPrompt(ctx); + expect(prompt).toContain("not found"); + }); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd packages/skill && pnpm test -- --reporter verbose src/__tests__/explain-builder.test.ts +``` + +**Step 3: Implement explain-builder.ts** + +```typescript +// packages/skill/src/explain-builder.ts +import type { KnowledgeGraph, GraphNode, GraphEdge, Layer } from "@understand-anything/core"; + +export interface ExplainContext { + projectName: string; + path: string; + targetNode: GraphNode | null; + childNodes: GraphNode[]; + connectedNodes: GraphNode[]; + relevantEdges: GraphEdge[]; + layer: Layer | null; +} + +/** + * Build a context for explaining a specific file or function. + * Supports file paths ("src/auth.ts") and path:function ("src/auth.ts:login"). + */ +export function buildExplainContext( + graph: KnowledgeGraph, + path: string, +): ExplainContext { + const { nodes, edges, layers } = graph; + + // Try exact filePath match first, then name-based matching + let targetNode: GraphNode | null = null; + + // Check for path:function format + const colonIdx = path.lastIndexOf(":"); + if (colonIdx > 0 && !path.includes("://")) { + const filePath = path.slice(0, colonIdx); + const funcName = path.slice(colonIdx + 1); + targetNode = nodes.find( + (n) => n.filePath === filePath && n.name === funcName, + ) ?? null; + } + + // Fall back to file path match + if (!targetNode) { + targetNode = nodes.find((n) => n.filePath === path) ?? null; + } + + if (!targetNode) { + return { + projectName: graph.project.name, + path, + targetNode: null, + childNodes: [], + connectedNodes: [], + relevantEdges: [], + layer: null, + }; + } + + // Find child nodes (contained by this node) + const childNodes = nodes.filter((n) => + edges.some( + (e) => e.source === targetNode!.id && e.target === n.id && e.type === "contains", + ), + ); + + // Also include children of children (e.g., file → class → methods) + const allRelatedIds = new Set([targetNode.id, ...childNodes.map((n) => n.id)]); + + // Find connected nodes (1-hop, excluding children) + const connectedIds = new Set(); + const relevantEdges: GraphEdge[] = []; + + for (const edge of edges) { + if (allRelatedIds.has(edge.source) || allRelatedIds.has(edge.target)) { + relevantEdges.push(edge); + if (allRelatedIds.has(edge.source) && !allRelatedIds.has(edge.target)) { + connectedIds.add(edge.target); + } + if (allRelatedIds.has(edge.target) && !allRelatedIds.has(edge.source)) { + connectedIds.add(edge.source); + } + } + } + + const connectedNodes = nodes.filter((n) => connectedIds.has(n.id)); + + // Find layer + const layer = layers.find((l) => l.nodeIds.includes(targetNode!.id)) ?? null; + + return { + projectName: graph.project.name, + path, + targetNode, + childNodes, + connectedNodes, + relevantEdges, + layer, + }; +} + +/** + * Format the explain context as a structured prompt for LLM consumption. + */ +export function formatExplainPrompt(ctx: ExplainContext): string { + if (!ctx.targetNode) { + return [ + `# Component Not Found`, + ``, + `The path "${ctx.path}" was not found in the knowledge graph for ${ctx.projectName}.`, + ``, + `Possible reasons:`, + `- The file hasn't been analyzed yet — try running /understand first`, + `- The path may be different in the graph — check the exact file path`, + `- The file may have been deleted or renamed since the last analysis`, + ].join("\n"); + } + + const { targetNode, childNodes, connectedNodes, relevantEdges, layer } = ctx; + const lines: string[] = []; + + lines.push(`# Deep Dive: ${targetNode.name}`); + lines.push(""); + lines.push(`**Type:** ${targetNode.type} | **Complexity:** ${targetNode.complexity}`); + if (targetNode.filePath) lines.push(`**File:** \`${targetNode.filePath}\``); + if (targetNode.lineRange) lines.push(`**Lines:** ${targetNode.lineRange[0]}-${targetNode.lineRange[1]}`); + lines.push(""); + lines.push(`**Summary:** ${targetNode.summary}`); + lines.push(""); + + if (layer) { + lines.push(`## Architectural Layer: ${layer.name}`); + lines.push(layer.description); + lines.push(""); + } + + if (childNodes.length > 0) { + lines.push("## Internal Components"); + for (const child of childNodes) { + lines.push(`- **${child.name}** (${child.type}): ${child.summary}`); + } + lines.push(""); + } + + if (connectedNodes.length > 0) { + lines.push("## Connected Components"); + for (const node of connectedNodes) { + lines.push(`- **${node.name}** (${node.type}): ${node.summary}`); + } + lines.push(""); + } + + if (relevantEdges.length > 0) { + const nodeMap = new Map( + [...[targetNode], ...childNodes, ...connectedNodes].map((n) => [n.id, n]), + ); + lines.push("## Relationships"); + for (const edge of relevantEdges) { + if (edge.type === "contains") continue; // skip containment (shown above) + const src = nodeMap.get(edge.source)?.name ?? edge.source; + const tgt = nodeMap.get(edge.target)?.name ?? edge.target; + const desc = edge.description ? ` — ${edge.description}` : ""; + lines.push(`- ${src} --[${edge.type}]--> ${tgt}${desc}`); + } + lines.push(""); + } + + if (targetNode.languageNotes) { + lines.push("## Language Notes"); + lines.push(targetNode.languageNotes); + lines.push(""); + } + + lines.push("## Instructions"); + lines.push("Provide a thorough explanation of this component:"); + lines.push("1. What it does and why it exists in the project"); + lines.push("2. How data flows through it (inputs, processing, outputs)"); + lines.push("3. How it interacts with connected components"); + lines.push("4. Any patterns, idioms, or design decisions worth noting"); + lines.push("5. Potential gotchas or areas of complexity"); + lines.push(""); + + return lines.join("\n"); +} +``` + +**Step 4: Run tests** + +```bash +cd packages/skill && pnpm test -- --reporter verbose src/__tests__/explain-builder.test.ts +``` + +**Step 5: Add exports + create skill definition** + +Add to `packages/skill/src/index.ts`: +```typescript +export { + buildExplainContext, + formatExplainPrompt, + type ExplainContext, +} from "./explain-builder.js"; +``` + +Create `packages/skill/.claude/skills/understand-explain.md`: +```markdown +--- +name: understand-explain +description: Deep-dive explanation of a specific file or function using the knowledge graph +arguments: path +--- + +# /understand-explain + +Provide a thorough, in-depth explanation of a specific code component. + +## Instructions + +1. Read the knowledge graph file at `.understand-anything/knowledge-graph.json` +2. If it doesn't exist, tell the user to run `/understand` first +3. Find the component matching the path: "${ARGUMENTS}" + - Supports file paths: `src/auth/login.ts` + - Supports function notation: `src/auth/login.ts:verifyToken` +4. Analyze the component in context: + - Its role in the architecture (which layer, why it exists) + - Internal structure (functions, classes it contains) + - External connections (what it imports, what calls it, what it depends on) + - Data flow (inputs → processing → outputs) +5. Explain clearly, assuming the reader may not know the programming language +6. Highlight any patterns, idioms, or complexity worth understanding +``` + +**Step 6: Build + test** + +```bash +cd packages/skill && pnpm build && pnpm test +``` + +**Step 7: Commit** + +```bash +git add packages/skill/src/explain-builder.ts packages/skill/src/__tests__/explain-builder.test.ts packages/skill/src/index.ts packages/skill/.claude/skills/understand-explain.md +git commit -m "feat(skill): add /understand-explain command for deep-dive file analysis" +``` + +--- + +## Task 3: /understand-onboard Skill — Onboarding Guide Generation + +**Files:** +- Create: `packages/skill/src/onboard-builder.ts` +- Create: `packages/skill/src/__tests__/onboard-builder.test.ts` +- Create: `packages/skill/.claude/skills/understand-onboard.md` +- Modify: `packages/skill/src/index.ts` (add exports) + +**Context:** The `/understand-onboard` skill generates a structured onboarding guide for new team members. It synthesizes the knowledge graph — project overview, architecture layers, key concepts, tour steps, and complexity hotspots — into a comprehensive document. The output is a well-structured markdown guide that can be committed to the repo or shared in a wiki. + +**Step 1: Write failing tests** + +```typescript +// packages/skill/src/__tests__/onboard-builder.test.ts +import { describe, it, expect } from "vitest"; +import { buildOnboardingGuide } from "../onboard-builder.js"; +import type { KnowledgeGraph } from "@understand-anything/core"; + +const sampleGraph: KnowledgeGraph = { + version: "1.0.0", + project: { + name: "test-project", + languages: ["typescript", "python"], + frameworks: ["express", "prisma"], + description: "A test REST API", + analyzedAt: "2026-03-14T00:00:00Z", + gitCommitHash: "abc123", + }, + nodes: [ + { id: "file:src/index.ts", type: "file", name: "index.ts", filePath: "src/index.ts", summary: "Entry point", tags: ["entry"], complexity: "simple" }, + { id: "file:src/service.ts", type: "file", name: "service.ts", filePath: "src/service.ts", summary: "Core service", tags: ["service"], complexity: "complex" }, + { id: "concept:auth", type: "concept", name: "Auth Flow", summary: "JWT-based authentication", tags: ["concept", "auth"], complexity: "complex" }, + ], + edges: [ + { source: "file:src/index.ts", target: "file:src/service.ts", type: "imports", direction: "forward", weight: 0.8 }, + ], + layers: [ + { id: "layer:api", name: "API Layer", description: "Routes and handlers", nodeIds: ["file:src/index.ts"] }, + { id: "layer:service", name: "Service Layer", description: "Business logic", nodeIds: ["file:src/service.ts"] }, + ], + tour: [ + { order: 1, title: "Start Here", description: "Begin with index.ts", nodeIds: ["file:src/index.ts"] }, + { order: 2, title: "Core Logic", description: "Service layer", nodeIds: ["file:src/service.ts"] }, + ], +}; + +describe("onboard-builder", () => { + it("includes project overview section", () => { + const guide = buildOnboardingGuide(sampleGraph); + expect(guide).toContain("# test-project"); + expect(guide).toContain("A test REST API"); + }); + + it("lists languages and frameworks", () => { + const guide = buildOnboardingGuide(sampleGraph); + expect(guide).toContain("typescript"); + expect(guide).toContain("express"); + }); + + it("includes architecture layers section", () => { + const guide = buildOnboardingGuide(sampleGraph); + expect(guide).toContain("## Architecture"); + expect(guide).toContain("API Layer"); + expect(guide).toContain("Service Layer"); + }); + + it("includes key concepts section", () => { + const guide = buildOnboardingGuide(sampleGraph); + expect(guide).toContain("## Key Concepts"); + expect(guide).toContain("Auth Flow"); + }); + + it("includes getting started / tour section", () => { + const guide = buildOnboardingGuide(sampleGraph); + expect(guide).toContain("## Getting Started"); + expect(guide).toContain("Start Here"); + }); + + it("includes complexity hotspots", () => { + const guide = buildOnboardingGuide(sampleGraph); + expect(guide).toContain("## Complexity Hotspots"); + expect(guide).toContain("service.ts"); + }); + + it("includes file map section", () => { + const guide = buildOnboardingGuide(sampleGraph); + expect(guide).toContain("## File Map"); + }); + + it("handles graph with no layers gracefully", () => { + const noLayers = { ...sampleGraph, layers: [] }; + const guide = buildOnboardingGuide(noLayers); + expect(guide).toContain("# test-project"); + }); + + it("handles graph with no tour gracefully", () => { + const noTour = { ...sampleGraph, tour: [] }; + const guide = buildOnboardingGuide(noTour); + expect(guide).toContain("# test-project"); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd packages/skill && pnpm test -- --reporter verbose src/__tests__/onboard-builder.test.ts +``` + +**Step 3: Implement onboard-builder.ts** + +```typescript +// packages/skill/src/onboard-builder.ts +import type { KnowledgeGraph } from "@understand-anything/core"; + +/** + * Generate a structured onboarding guide from the knowledge graph. + * Output is standalone markdown suitable for a README, wiki, or docs. + */ +export function buildOnboardingGuide(graph: KnowledgeGraph): string { + const { project, nodes, edges, layers, tour } = graph; + const lines: string[] = []; + + // --- Project Overview --- + lines.push(`# ${project.name}`); + lines.push(""); + lines.push(`> ${project.description}`); + lines.push(""); + lines.push(`| | |`); + lines.push(`|---|---|`); + lines.push(`| **Languages** | ${project.languages.join(", ")} |`); + lines.push(`| **Frameworks** | ${project.frameworks.join(", ")} |`); + lines.push(`| **Components** | ${nodes.length} nodes, ${edges.length} relationships |`); + lines.push(`| **Last Analyzed** | ${project.analyzedAt} |`); + lines.push(""); + + // --- Architecture --- + if (layers.length > 0) { + lines.push("## Architecture"); + lines.push(""); + lines.push("The project is organized into the following layers:"); + lines.push(""); + for (const layer of layers) { + const memberNames = layer.nodeIds + .map((id) => nodes.find((n) => n.id === id)?.name) + .filter(Boolean); + lines.push(`### ${layer.name}`); + lines.push(""); + lines.push(layer.description); + lines.push(""); + if (memberNames.length > 0) { + lines.push(`Key components: ${memberNames.join(", ")}`); + lines.push(""); + } + } + } + + // --- Key Concepts --- + const conceptNodes = nodes.filter((n) => n.type === "concept"); + if (conceptNodes.length > 0) { + lines.push("## Key Concepts"); + lines.push(""); + lines.push("Important architectural and domain concepts to understand:"); + lines.push(""); + for (const concept of conceptNodes) { + lines.push(`### ${concept.name}`); + lines.push(""); + lines.push(concept.summary); + lines.push(""); + } + } + + // --- Getting Started (Tour) --- + if (tour.length > 0) { + lines.push("## Getting Started"); + lines.push(""); + lines.push("Follow this guided tour to understand the codebase:"); + lines.push(""); + for (const step of tour) { + const stepNodes = step.nodeIds + .map((id) => nodes.find((n) => n.id === id)) + .filter(Boolean); + lines.push(`### ${step.order}. ${step.title}`); + lines.push(""); + lines.push(step.description); + lines.push(""); + if (stepNodes.length > 0) { + lines.push("**Files to look at:**"); + for (const node of stepNodes) { + if (node!.filePath) { + lines.push(`- \`${node!.filePath}\` — ${node!.summary}`); + } + } + lines.push(""); + } + if (step.languageLesson) { + lines.push(`> **Language Tip:** ${step.languageLesson}`); + lines.push(""); + } + } + } + + // --- File Map --- + const fileNodes = nodes.filter((n) => n.type === "file" && n.filePath); + if (fileNodes.length > 0) { + lines.push("## File Map"); + lines.push(""); + lines.push("| File | Purpose | Complexity |"); + lines.push("|------|---------|------------|"); + for (const node of fileNodes) { + lines.push(`| \`${node.filePath}\` | ${node.summary} | ${node.complexity} |`); + } + lines.push(""); + } + + // --- Complexity Hotspots --- + const complexNodes = nodes.filter((n) => n.complexity === "complex"); + if (complexNodes.length > 0) { + lines.push("## Complexity Hotspots"); + lines.push(""); + lines.push("These components are the most complex and deserve extra attention:"); + lines.push(""); + for (const node of complexNodes) { + lines.push(`- **${node.name}** (${node.type}): ${node.summary}`); + } + lines.push(""); + } + + // --- Footer --- + lines.push("---"); + lines.push(""); + lines.push(`*Generated by [Understand Anything](https://github.com/anthropics/understand-anything) from knowledge graph v${graph.version}*`); + lines.push(""); + + return lines.join("\n"); +} +``` + +**Step 4: Run tests** + +```bash +cd packages/skill && pnpm test -- --reporter verbose src/__tests__/onboard-builder.test.ts +``` + +**Step 5: Add exports + create skill definition** + +Add to `packages/skill/src/index.ts`: +```typescript +export { buildOnboardingGuide } from "./onboard-builder.js"; +``` + +Create `packages/skill/.claude/skills/understand-onboard.md`: +```markdown +--- +name: understand-onboard +description: Generate a structured onboarding guide for new team members using the knowledge graph +--- + +# /understand-onboard + +Generate a comprehensive onboarding guide from the project's knowledge graph. + +## Instructions + +1. Read the knowledge graph at `.understand-anything/knowledge-graph.json` +2. If it doesn't exist, tell the user to run `/understand` first +3. Generate a structured onboarding guide that includes: + - Project overview (name, languages, frameworks, description) + - Architecture layers and their responsibilities + - Key concepts to understand + - Guided tour (step-by-step walkthrough) + - File map (what each key file does) + - Complexity hotspots (what to be careful with) +4. Format as clean markdown +5. Offer to save the guide to `docs/ONBOARDING.md` in the project +6. Suggest the user commit it to the repo for the team +``` + +**Step 6: Build + test** + +```bash +cd packages/skill && pnpm build && pnpm test +``` + +**Step 7: Commit** + +```bash +git add packages/skill/src/onboard-builder.ts packages/skill/src/__tests__/onboard-builder.test.ts packages/skill/src/index.ts packages/skill/.claude/skills/understand-onboard.md +git commit -m "feat(skill): add /understand-onboard command for team onboarding guides" +``` + +--- + +## Task 4: Plugin Registry + Loader (Core) + +**Files:** +- Create: `packages/core/src/plugins/registry.ts` +- Create: `packages/core/src/__tests__/plugin-registry.test.ts` +- Modify: `packages/core/src/index.ts` (add exports) + +**Context:** The `AnalyzerPlugin` interface already exists in `packages/core/src/types.ts`. Currently only `TreeSitterPlugin` implements it. This task creates a plugin registry that discovers, registers, and manages analyzer plugins. The registry maps file extensions to plugins and provides a unified `analyzeFile` entrypoint. This is the foundation for community plugins. + +**Step 1: Write failing tests** + +```typescript +// packages/core/src/__tests__/plugin-registry.test.ts +import { describe, it, expect } from "vitest"; +import { PluginRegistry } from "../plugins/registry.js"; +import type { AnalyzerPlugin, StructuralAnalysis, ImportResolution } from "../types.js"; + +const emptyAnalysis: StructuralAnalysis = { + functions: [], + classes: [], + imports: [], + exports: [], +}; + +function createMockPlugin(name: string, languages: string[]): AnalyzerPlugin { + return { + name, + languages, + analyzeFile: () => ({ ...emptyAnalysis }), + resolveImports: () => [], + }; +} + +describe("PluginRegistry", () => { + it("registers a plugin", () => { + const registry = new PluginRegistry(); + const plugin = createMockPlugin("test", ["typescript"]); + registry.register(plugin); + expect(registry.getPlugins()).toHaveLength(1); + }); + + it("finds plugin by language", () => { + const registry = new PluginRegistry(); + const plugin = createMockPlugin("ts-plugin", ["typescript", "javascript"]); + registry.register(plugin); + expect(registry.getPluginForLanguage("typescript")).toBe(plugin); + expect(registry.getPluginForLanguage("javascript")).toBe(plugin); + }); + + it("returns null for unsupported language", () => { + const registry = new PluginRegistry(); + registry.register(createMockPlugin("ts-plugin", ["typescript"])); + expect(registry.getPluginForLanguage("python")).toBeNull(); + }); + + it("finds plugin by file extension", () => { + const registry = new PluginRegistry(); + const plugin = createMockPlugin("ts-plugin", ["typescript"]); + registry.register(plugin); + expect(registry.getPluginForFile("src/index.ts")).toBe(plugin); + expect(registry.getPluginForFile("src/app.tsx")).toBe(plugin); + }); + + it("maps common extensions to languages", () => { + const registry = new PluginRegistry(); + const plugin = createMockPlugin("multi", ["python", "go", "rust"]); + registry.register(plugin); + expect(registry.getPluginForFile("main.py")).toBe(plugin); + expect(registry.getPluginForFile("main.go")).toBe(plugin); + expect(registry.getPluginForFile("main.rs")).toBe(plugin); + }); + + it("lists all registered plugins", () => { + const registry = new PluginRegistry(); + registry.register(createMockPlugin("a", ["typescript"])); + registry.register(createMockPlugin("b", ["python"])); + expect(registry.getPlugins()).toHaveLength(2); + }); + + it("lists supported languages", () => { + const registry = new PluginRegistry(); + registry.register(createMockPlugin("a", ["typescript", "javascript"])); + registry.register(createMockPlugin("b", ["python"])); + const langs = registry.getSupportedLanguages(); + expect(langs).toContain("typescript"); + expect(langs).toContain("python"); + }); + + it("unregisters a plugin by name", () => { + const registry = new PluginRegistry(); + registry.register(createMockPlugin("removable", ["typescript"])); + expect(registry.getPlugins()).toHaveLength(1); + registry.unregister("removable"); + expect(registry.getPlugins()).toHaveLength(0); + }); + + it("later registration takes priority for same language", () => { + const registry = new PluginRegistry(); + const first = createMockPlugin("first", ["typescript"]); + const second = createMockPlugin("second", ["typescript"]); + registry.register(first); + registry.register(second); + // Second registration wins + expect(registry.getPluginForLanguage("typescript")?.name).toBe("second"); + }); + + it("analyzeFile delegates to correct plugin", () => { + const registry = new PluginRegistry(); + const plugin = createMockPlugin("ts-plugin", ["typescript"]); + plugin.analyzeFile = () => ({ + ...emptyAnalysis, + functions: [{ name: "hello", lineRange: [1, 5], params: [] }], + }); + registry.register(plugin); + + const result = registry.analyzeFile("src/test.ts", "const x = 1;"); + expect(result).not.toBeNull(); + expect(result!.functions).toHaveLength(1); + }); + + it("analyzeFile returns null for unsupported files", () => { + const registry = new PluginRegistry(); + registry.register(createMockPlugin("ts-plugin", ["typescript"])); + const result = registry.analyzeFile("main.py", "print('hello')"); + expect(result).toBeNull(); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd packages/core && pnpm test -- --reporter verbose src/__tests__/plugin-registry.test.ts +``` + +**Step 3: Implement registry.ts** + +```typescript +// packages/core/src/plugins/registry.ts +import type { AnalyzerPlugin, StructuralAnalysis, ImportResolution } from "../types.js"; + +// Map file extensions to language names +const EXTENSION_TO_LANGUAGE: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + py: "python", + go: "go", + rs: "rust", + rb: "ruby", + java: "java", + kt: "kotlin", + cs: "csharp", + cpp: "cpp", + c: "c", + swift: "swift", + php: "php", +}; + +/** + * Registry for analyzer plugins. Maps languages to plugins and provides + * a unified interface for analyzing files across languages. + */ +export class PluginRegistry { + private plugins: AnalyzerPlugin[] = []; + private languageMap = new Map(); + + /** + * Register an analyzer plugin. Later registrations take priority + * for overlapping languages. + */ + register(plugin: AnalyzerPlugin): void { + this.plugins.push(plugin); + for (const lang of plugin.languages) { + this.languageMap.set(lang, plugin); + } + } + + /** + * Remove a plugin by name. + */ + unregister(name: string): void { + const plugin = this.plugins.find((p) => p.name === name); + if (!plugin) return; + + this.plugins = this.plugins.filter((p) => p.name !== name); + + // Rebuild language map + this.languageMap.clear(); + for (const p of this.plugins) { + for (const lang of p.languages) { + this.languageMap.set(lang, p); + } + } + } + + /** + * Get plugin for a language name (e.g., "typescript", "python"). + */ + getPluginForLanguage(language: string): AnalyzerPlugin | null { + return this.languageMap.get(language) ?? null; + } + + /** + * Get plugin for a file path based on its extension. + */ + getPluginForFile(filePath: string): AnalyzerPlugin | null { + const ext = filePath.split(".").pop()?.toLowerCase(); + if (!ext) return null; + const language = EXTENSION_TO_LANGUAGE[ext]; + if (!language) return null; + return this.getPluginForLanguage(language); + } + + /** + * Analyze a file using the appropriate plugin. + * Returns null if no plugin supports the file type. + */ + analyzeFile(filePath: string, content: string): StructuralAnalysis | null { + const plugin = this.getPluginForFile(filePath); + if (!plugin) return null; + return plugin.analyzeFile(filePath, content); + } + + /** + * Resolve imports for a file using the appropriate plugin. + * Returns null if no plugin supports the file type. + */ + resolveImports(filePath: string, content: string): ImportResolution[] | null { + const plugin = this.getPluginForFile(filePath); + if (!plugin) return null; + return plugin.resolveImports(filePath, content); + } + + /** + * Get all registered plugins. + */ + getPlugins(): AnalyzerPlugin[] { + return [...this.plugins]; + } + + /** + * Get all supported languages across all plugins. + */ + getSupportedLanguages(): string[] { + return [...this.languageMap.keys()]; + } +} +``` + +**Step 4: Run tests** + +```bash +cd packages/core && pnpm test -- --reporter verbose src/__tests__/plugin-registry.test.ts +``` + +**Step 5: Add exports to index.ts** + +```typescript +export { PluginRegistry } from "./plugins/registry.js"; +``` + +**Step 6: Build + full test suite** + +```bash +cd packages/core && pnpm build && pnpm test +``` + +**Step 7: Commit** + +```bash +git add packages/core/src/plugins/registry.ts packages/core/src/__tests__/plugin-registry.test.ts packages/core/src/index.ts +git commit -m "feat(core): add plugin registry for community analyzer plugins" +``` + +--- + +## Task 5: Plugin Configuration + Discovery + +**Files:** +- Create: `packages/core/src/plugins/discovery.ts` +- Create: `packages/core/src/__tests__/plugin-discovery.test.ts` +- Modify: `packages/core/src/index.ts` (add exports) + +**Context:** The plugin registry from Task 4 manages runtime plugins, but we also need a way to discover and configure plugins from the project's `.understand-anything/` directory. This task adds a plugin configuration file schema and a discovery mechanism that scans for installed plugins and auto-registers them. + +**Step 1: Write failing tests** + +```typescript +// packages/core/src/__tests__/plugin-discovery.test.ts +import { describe, it, expect } from "vitest"; +import { + parsePluginConfig, + type PluginConfig, + type PluginEntry, + DEFAULT_PLUGIN_CONFIG, +} from "../plugins/discovery.js"; + +describe("plugin-discovery", () => { + describe("parsePluginConfig", () => { + it("parses valid config JSON", () => { + const json = JSON.stringify({ + plugins: [ + { name: "tree-sitter", enabled: true, languages: ["typescript", "javascript"] }, + { name: "python-ast", enabled: false, languages: ["python"] }, + ], + }); + const config = parsePluginConfig(json); + expect(config.plugins).toHaveLength(2); + expect(config.plugins[0].name).toBe("tree-sitter"); + expect(config.plugins[1].enabled).toBe(false); + }); + + it("returns default config for invalid JSON", () => { + const config = parsePluginConfig("not json"); + expect(config).toEqual(DEFAULT_PLUGIN_CONFIG); + }); + + it("returns default config for empty string", () => { + const config = parsePluginConfig(""); + expect(config).toEqual(DEFAULT_PLUGIN_CONFIG); + }); + + it("filters out entries missing required fields", () => { + const json = JSON.stringify({ + plugins: [ + { name: "valid", enabled: true, languages: ["typescript"] }, + { enabled: true, languages: ["python"] }, // missing name + { name: "no-langs", enabled: true }, // missing languages + ], + }); + const config = parsePluginConfig(json); + expect(config.plugins).toHaveLength(1); + expect(config.plugins[0].name).toBe("valid"); + }); + + it("defaults enabled to true when omitted", () => { + const json = JSON.stringify({ + plugins: [ + { name: "tree-sitter", languages: ["typescript"] }, + ], + }); + const config = parsePluginConfig(json); + expect(config.plugins[0].enabled).toBe(true); + }); + }); + + describe("DEFAULT_PLUGIN_CONFIG", () => { + it("includes tree-sitter as enabled by default", () => { + expect(DEFAULT_PLUGIN_CONFIG.plugins).toHaveLength(1); + expect(DEFAULT_PLUGIN_CONFIG.plugins[0].name).toBe("tree-sitter"); + expect(DEFAULT_PLUGIN_CONFIG.plugins[0].enabled).toBe(true); + }); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd packages/core && pnpm test -- --reporter verbose src/__tests__/plugin-discovery.test.ts +``` + +**Step 3: Implement discovery.ts** + +```typescript +// packages/core/src/plugins/discovery.ts + +export interface PluginEntry { + name: string; + enabled: boolean; + languages: string[]; + options?: Record; +} + +export interface PluginConfig { + plugins: PluginEntry[]; +} + +export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { + plugins: [ + { + name: "tree-sitter", + enabled: true, + languages: ["typescript", "javascript"], + }, + ], +}; + +/** + * Parse a plugin config JSON string. + * Returns DEFAULT_PLUGIN_CONFIG if parsing fails. + */ +export function parsePluginConfig(jsonString: string): PluginConfig { + if (!jsonString.trim()) return { ...DEFAULT_PLUGIN_CONFIG }; + + try { + const parsed = JSON.parse(jsonString); + if (!parsed || !Array.isArray(parsed.plugins)) { + return { ...DEFAULT_PLUGIN_CONFIG }; + } + + const plugins = parsed.plugins + .filter((entry: unknown): entry is Record => { + if (typeof entry !== "object" || entry === null) return false; + const e = entry as Record; + return ( + typeof e.name === "string" && + e.name.length > 0 && + Array.isArray(e.languages) && + e.languages.length > 0 + ); + }) + .map((e: Record): PluginEntry => ({ + name: e.name as string, + enabled: typeof e.enabled === "boolean" ? e.enabled : true, + languages: e.languages as string[], + ...(e.options ? { options: e.options as Record } : {}), + })); + + return { plugins }; + } catch { + return { ...DEFAULT_PLUGIN_CONFIG }; + } +} + +/** + * Serialize a plugin config to JSON for saving. + */ +export function serializePluginConfig(config: PluginConfig): string { + return JSON.stringify(config, null, 2); +} +``` + +**Step 4: Run tests** + +```bash +cd packages/core && pnpm test -- --reporter verbose src/__tests__/plugin-discovery.test.ts +``` + +**Step 5: Add exports** + +```typescript +export { + parsePluginConfig, + serializePluginConfig, + DEFAULT_PLUGIN_CONFIG, + type PluginConfig, + type PluginEntry, +} from "./plugins/discovery.js"; +``` + +**Step 6: Build + test** + +```bash +cd packages/core && pnpm build && pnpm test +``` + +**Step 7: Commit** + +```bash +git add packages/core/src/plugins/discovery.ts packages/core/src/__tests__/plugin-discovery.test.ts packages/core/src/index.ts +git commit -m "feat(core): add plugin configuration and discovery system" +``` + +--- + +## Task 6: Embedding-Based Semantic Search (Core) + +**Files:** +- Create: `packages/core/src/embedding-search.ts` +- Create: `packages/core/src/__tests__/embedding-search.test.ts` +- Modify: `packages/core/src/index.ts` (add exports) + +**Context:** The current `SearchEngine` uses fuse.js for fuzzy keyword matching. Embedding-based search enables true semantic queries like "find code that handles authentication" even if the word "authentication" doesn't appear in the node data. This task adds a `SemanticSearchEngine` that stores and searches vector embeddings. The embeddings themselves are generated externally (by calling an embedding API) — this module handles storage and cosine similarity search. It falls back to the existing `SearchEngine` when no embeddings are available. + +**Step 1: Write failing tests** + +```typescript +// packages/core/src/__tests__/embedding-search.test.ts +import { describe, it, expect } from "vitest"; +import { SemanticSearchEngine, cosineSimilarity } from "../embedding-search.js"; +import type { GraphNode } from "../types.js"; + +const nodes: GraphNode[] = [ + { id: "n1", type: "file", name: "auth.ts", summary: "Authentication module", tags: ["auth"], complexity: "moderate" }, + { id: "n2", type: "file", name: "db.ts", summary: "Database connection", tags: ["db"], complexity: "simple" }, + { id: "n3", type: "function", name: "login", summary: "User login handler", tags: ["auth", "login"], complexity: "moderate" }, +]; + +// Simple unit vectors for testing +const embeddings: Record = { + n1: [1, 0, 0, 0], + n2: [0, 1, 0, 0], + n3: [0.9, 0, 0.1, 0], +}; + +describe("embedding-search", () => { + describe("cosineSimilarity", () => { + it("returns 1 for identical vectors", () => { + expect(cosineSimilarity([1, 0, 0], [1, 0, 0])).toBeCloseTo(1); + }); + + it("returns 0 for orthogonal vectors", () => { + expect(cosineSimilarity([1, 0, 0], [0, 1, 0])).toBeCloseTo(0); + }); + + it("returns high similarity for similar vectors", () => { + const sim = cosineSimilarity([1, 0, 0], [0.9, 0.1, 0]); + expect(sim).toBeGreaterThan(0.9); + }); + + it("handles zero vectors", () => { + expect(cosineSimilarity([0, 0, 0], [1, 0, 0])).toBe(0); + }); + }); + + describe("SemanticSearchEngine", () => { + it("returns results sorted by similarity", () => { + const engine = new SemanticSearchEngine(nodes, embeddings); + const queryEmbedding = [1, 0, 0, 0]; // most similar to n1 and n3 + const results = engine.search(queryEmbedding); + expect(results[0].nodeId).toBe("n1"); + }); + + it("respects limit parameter", () => { + const engine = new SemanticSearchEngine(nodes, embeddings); + const results = engine.search([1, 0, 0, 0], { limit: 2 }); + expect(results).toHaveLength(2); + }); + + it("respects threshold parameter", () => { + const engine = new SemanticSearchEngine(nodes, embeddings); + const results = engine.search([1, 0, 0, 0], { threshold: 0.5 }); + // n2 has 0 similarity, should be filtered out + const ids = results.map((r) => r.nodeId); + expect(ids).not.toContain("n2"); + }); + + it("filters by node type", () => { + const engine = new SemanticSearchEngine(nodes, embeddings); + const results = engine.search([1, 0, 0, 0], { types: ["function"] }); + expect(results.every((r) => { + const node = nodes.find((n) => n.id === r.nodeId); + return node?.type === "function"; + })).toBe(true); + }); + + it("returns empty for nodes without embeddings", () => { + const engine = new SemanticSearchEngine(nodes, {}); + const results = engine.search([1, 0, 0, 0]); + expect(results).toHaveLength(0); + }); + + it("hasEmbeddings returns true when embeddings exist", () => { + const engine = new SemanticSearchEngine(nodes, embeddings); + expect(engine.hasEmbeddings()).toBe(true); + }); + + it("hasEmbeddings returns false when empty", () => { + const engine = new SemanticSearchEngine(nodes, {}); + expect(engine.hasEmbeddings()).toBe(false); + }); + + it("addEmbedding updates the search index", () => { + const engine = new SemanticSearchEngine(nodes, {}); + expect(engine.hasEmbeddings()).toBe(false); + engine.addEmbedding("n1", [1, 0, 0, 0]); + expect(engine.hasEmbeddings()).toBe(true); + }); + }); +}); +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd packages/core && pnpm test -- --reporter verbose src/__tests__/embedding-search.test.ts +``` + +**Step 3: Implement embedding-search.ts** + +```typescript +// packages/core/src/embedding-search.ts +import type { GraphNode } from "./types.js"; +import type { SearchResult } from "./search.js"; + +export interface SemanticSearchOptions { + limit?: number; + threshold?: number; + types?: string[]; +} + +/** + * Compute cosine similarity between two vectors. + * Returns 0 if either vector has zero magnitude. + */ +export function cosineSimilarity(a: number[], b: number[]): number { + let dot = 0; + let magA = 0; + let magB = 0; + + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + magA += a[i] * a[i]; + magB += b[i] * b[i]; + } + + magA = Math.sqrt(magA); + magB = Math.sqrt(magB); + + if (magA === 0 || magB === 0) return 0; + return dot / (magA * magB); +} + +/** + * Semantic search engine using vector embeddings. + * Stores pre-computed embeddings for graph nodes and performs + * cosine similarity search against query embeddings. + */ +export class SemanticSearchEngine { + private nodes: GraphNode[]; + private embeddings: Map; + + constructor(nodes: GraphNode[], embeddings: Record) { + this.nodes = nodes; + this.embeddings = new Map(Object.entries(embeddings)); + } + + /** + * Check if any embeddings are loaded. + */ + hasEmbeddings(): boolean { + return this.embeddings.size > 0; + } + + /** + * Add or update an embedding for a node. + */ + addEmbedding(nodeId: string, embedding: number[]): void { + this.embeddings.set(nodeId, embedding); + } + + /** + * Search nodes by similarity to a query embedding. + * Returns SearchResult[] compatible with the existing search interface. + */ + search( + queryEmbedding: number[], + options?: SemanticSearchOptions, + ): SearchResult[] { + const limit = options?.limit ?? 10; + const threshold = options?.threshold ?? 0; + const typeFilter = options?.types; + + const scored: Array<{ nodeId: string; score: number }> = []; + + for (const node of this.nodes) { + // Type filter + if (typeFilter && !typeFilter.includes(node.type)) continue; + + const embedding = this.embeddings.get(node.id); + if (!embedding) continue; + + const similarity = cosineSimilarity(queryEmbedding, embedding); + if (similarity >= threshold) { + // Convert similarity (0-1, higher=better) to score (0-1, lower=better) + // to match the SearchResult interface convention from fuse.js + scored.push({ nodeId: node.id, score: 1 - similarity }); + } + } + + // Sort by score ascending (lower = more similar) + scored.sort((a, b) => a.score - b.score); + + return scored.slice(0, limit); + } + + /** + * Update the node list (e.g., after graph reload). + */ + updateNodes(nodes: GraphNode[]): void { + this.nodes = nodes; + } +} +``` + +**Step 4: Run tests** + +```bash +cd packages/core && pnpm test -- --reporter verbose src/__tests__/embedding-search.test.ts +``` + +**Step 5: Add exports** + +```typescript +export { + SemanticSearchEngine, + cosineSimilarity, + type SemanticSearchOptions, +} from "./embedding-search.js"; +``` + +**Step 6: Build + test** + +```bash +cd packages/core && pnpm build && pnpm test +``` + +**Step 7: Commit** + +```bash +git add packages/core/src/embedding-search.ts packages/core/src/__tests__/embedding-search.test.ts packages/core/src/index.ts +git commit -m "feat(core): add embedding-based semantic search engine" +``` + +--- + +## Task 7: Embedding Search Dashboard Integration + +**Files:** +- Modify: `packages/dashboard/src/store.ts` (add semantic search state) +- Modify: `packages/dashboard/src/components/SearchBar.tsx` (semantic search toggle) + +**Context:** This task integrates the `SemanticSearchEngine` into the dashboard. When the knowledge graph includes pre-computed embeddings (stored as a separate field or companion file), the SearchBar offers a toggle between "Fuzzy" and "Semantic" search modes. The semantic mode uses vector similarity for queries like "where is authentication handled" even if those exact words aren't in any node. For MVP, we'll add the UI toggle and wiring — actual embedding generation requires an API call that would be part of the analysis pipeline. + +**Step 1: Add semantic search state to the store** + +In `packages/dashboard/src/store.ts`: + +```typescript +// Add to interface +searchMode: "fuzzy" | "semantic"; +setSearchMode: (mode: "fuzzy" | "semantic") => void; + +// Add to implementation +searchMode: "fuzzy", +setSearchMode: (mode) => set({ searchMode: mode }), +``` + +Update `setSearchQuery` to check `searchMode`: +```typescript +setSearchQuery: (query) => { + const engine = get().searchEngine; + const mode = get().searchMode; + if (!engine || !query.trim()) { + set({ searchQuery: query, searchResults: [] }); + return; + } + // Currently both modes use the same fuzzy engine + // When embeddings are available, "semantic" mode will use SemanticSearchEngine + const searchResults = engine.search(query); + set({ searchQuery: query, searchResults }); +}, +``` + +**Step 2: Add search mode toggle to SearchBar** + +In `packages/dashboard/src/components/SearchBar.tsx`, add: + +```tsx +const searchMode = useDashboardStore((s) => s.searchMode); +const setSearchMode = useDashboardStore((s) => s.setSearchMode); + +// Add toggle next to the search input: +
+ + +
+``` + +**Step 3: Verify dashboard compiles** + +```bash +cd packages/dashboard && pnpm build +``` + +**Step 4: Commit** + +```bash +git add packages/dashboard/src/store.ts packages/dashboard/src/components/SearchBar.tsx +git commit -m "feat(dashboard): add fuzzy/semantic search mode toggle" +``` + +--- + +## Verification Checklist + +After all tasks are complete: + +1. `cd packages/core && pnpm build && pnpm test` — all tests pass (existing + ~35 new) +2. `cd packages/skill && pnpm build && pnpm test` — all tests pass (existing 14 + ~25 new) +3. `cd packages/dashboard && pnpm build` — compiles without errors +4. Skill definitions exist: + - `packages/skill/.claude/skills/understand-diff.md` + - `packages/skill/.claude/skills/understand-explain.md` + - `packages/skill/.claude/skills/understand-onboard.md` +5. Plugin registry works: + - `PluginRegistry.register()`, `getPluginForFile()`, `analyzeFile()` + - `parsePluginConfig()` handles valid/invalid JSON +6. Semantic search: + - `cosineSimilarity()` produces correct values + - `SemanticSearchEngine.search()` returns sorted results + - Dashboard toggle renders and switches modes +7. All existing Phase 1 + Phase 2 + Phase 3 features still work