diff --git a/README.md b/README.md index de5d536..9af0655 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ CodePlans sits between your issue tracker and your architecture diagram: | Work items — features, bugs & tech debt register, linkable to code plans | ✅ Available | | Per-asset branch & PR tracking on code plans | ✅ Available | | Asset dependency mapping & plan impact analysis | ✅ Available | +| Asset Atlas — live system map with health/debt/activity lenses, plus grid & table views | ✅ Available | | Analytics wired to real data (velocity, effort accuracy, debt by product) | ✅ Available | | Activity feed | ✅ Available | | GitHub, GitLab, Jira, Asana & Linear integrations (pull-only mirror into work items) | ✅ Available | @@ -411,7 +412,8 @@ Your agent can then read plans/work items/tech debt and (with a write-scope key) - [x] **v0.4.2 — Version-structured history & design log:** version ladder on asset history, user/agent-authored design notes, plan-side release picker, 10 new MCP tools (39 total) - [x] **v0.4.3 — AI drafting (feature-flagged):** release notes & design notes drafted from delivered work, always landing in an editor - [x] **v0.4.4 — Asset Record (Phase A):** per-asset capabilities register with delivery lineage, graduation from resolved work items, derived known-issues/debt sections, tombstoned removals, `get_asset_record` + `graduate_work_item` MCP tools (41 total) -- [ ] **v0.4.5+ — Reconciliation & round-trip engineering** (see [`docs/specs/asset-record-spec.md`](docs/specs/asset-record-spec.md)): agent reconciliation proposals, release publishing +- [x] **v0.4.5 — Asset Atlas:** top-level Assets view — a live system map (products as columns, dependency edges, health/debt/activity lenses, blast-radius hover) plus grid and sortable table views +- [ ] **v0.4.6+ — Reconciliation & round-trip engineering** (see [`docs/specs/asset-record-spec.md`](docs/specs/asset-record-spec.md)): agent reconciliation proposals, release publishing - [ ] AI-assisted effort estimation - [ ] Billing / subscription management (hosted tier, optional & feature-flagged) diff --git a/app/(dashboard)/assets/asset-map.tsx b/app/(dashboard)/assets/asset-map.tsx new file mode 100644 index 0000000..5fa7403 --- /dev/null +++ b/app/(dashboard)/assets/asset-map.tsx @@ -0,0 +1,271 @@ +'use client' + +import { useMemo, useState } from 'react' +import Link from 'next/link' +import { Badge } from '@/components/ui/badge' +import { HeartPulse, Wrench, Activity } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { AssetInventoryRow, DependencyEdge } from '@/lib/db/queries' +import { assetTypeIcons } from './assets-client' + +/** + * The system map: products as columns, assets as nodes, dependency edges as + * curves. Pure HTML nodes over an SVG underlay — deterministic layout, no + * graph library. Lenses recolor the same map by health, debt, or activity. + */ + +const PAD = 16 +const HEADER_H = 40 +const COL_W = 230 +const COL_GAP = 90 +const NODE_H = 56 +const NODE_GAP = 16 + +type Lens = 'health' | 'debt' | 'activity' + +const lensOptions: { key: Lens; label: string; icon: typeof HeartPulse }[] = [ + { key: 'health', label: 'Health', icon: HeartPulse }, + { key: 'debt', label: 'Debt', icon: Wrench }, + { key: 'activity', label: 'Activity', icon: Activity }, +] + +function lensBorder(lens: Lens, a: AssetInventoryRow): string { + if (lens === 'health') { + return a.health === 'critical' ? 'border-l-destructive' : a.health === 'warning' ? 'border-l-warning' : 'border-l-accent' + } + if (lens === 'debt') { + return a.effectiveDebtScore >= 50 ? 'border-l-destructive' : a.effectiveDebtScore >= 25 ? 'border-l-warning' : 'border-l-accent' + } + return a.activePlanCount > 0 ? 'border-l-chart-1' : 'border-l-muted-foreground/30' +} + +function lensDetail(lens: Lens, a: AssetInventoryRow): string { + if (lens === 'health') return a.health + if (lens === 'debt') return `debt ${a.effectiveDebtScore}${a.openDebtCount > 0 ? ` · ${a.openDebtCount} open` : ''}` + return a.activePlanCount > 0 + ? `${a.activePlanCount} active plan${a.activePlanCount === 1 ? '' : 's'}` + : 'quiet' +} + +const edgeDash: Record = { + depends_on: undefined, + integrates_with: '6 4', + aggregates: '2 4', +} + +type LaidOutNode = { asset: AssetInventoryRow; x: number; y: number } + +export function AssetMap({ assets, edges }: { assets: AssetInventoryRow[]; edges: DependencyEdge[] }) { + const [lens, setLens] = useState('health') + const [hoverId, setHoverId] = useState(null) + + const layout = useMemo(() => { + // Column per product, in first-seen order. + const productOrder: { id: string; name: string }[] = [] + const byProduct = new Map() + for (const a of assets) { + if (!byProduct.has(a.productId)) { + byProduct.set(a.productId, []) + productOrder.push({ id: a.productId, name: a.productName }) + } + byProduct.get(a.productId)!.push(a) + } + + // Two barycenter sweeps: order nodes within a column by the average row + // of their neighbors, so cross-column edges stay short and legible. + const neighbor = new Map() + for (const e of edges) { + neighbor.set(e.sourceAssetId, [...(neighbor.get(e.sourceAssetId) ?? []), e.targetAssetId]) + neighbor.set(e.targetAssetId, [...(neighbor.get(e.targetAssetId) ?? []), e.sourceAssetId]) + } + const rowOf = new Map() + for (const p of productOrder) byProduct.get(p.id)!.forEach((a, i) => rowOf.set(a.id, i)) + for (let sweep = 0; sweep < 2; sweep++) { + for (const p of productOrder) { + const col = byProduct.get(p.id)! + col.sort((a, b) => { + const bary = (id: string) => { + const ns = neighbor.get(id) + if (!ns || ns.length === 0) return rowOf.get(id)! + return ns.reduce((sum, n) => sum + (rowOf.get(n) ?? 0), 0) / ns.length + } + return bary(a.id) - bary(b.id) + }) + col.forEach((a, i) => rowOf.set(a.id, i)) + } + } + + const nodes = new Map() + productOrder.forEach((p, colIdx) => { + byProduct.get(p.id)!.forEach((a, rowIdx) => { + nodes.set(a.id, { + asset: a, + x: PAD + colIdx * (COL_W + COL_GAP), + y: PAD + HEADER_H + rowIdx * (NODE_H + NODE_GAP), + }) + }) + }) + const maxRows = Math.max(...productOrder.map((p) => byProduct.get(p.id)!.length)) + return { + nodes, + products: productOrder, + // The trailing 90px keeps same-column arcs (which bow out the right side + // of the last column) inside the canvas. + width: PAD * 2 + productOrder.length * COL_W + (productOrder.length - 1) * COL_GAP + 90, + height: PAD * 2 + HEADER_H + maxRows * (NODE_H + NODE_GAP) - NODE_GAP, + } + }, [assets, edges]) + + const neighborsOfHover = useMemo(() => { + if (!hoverId) return null + const set = new Set([hoverId]) + for (const e of edges) { + if (e.sourceAssetId === hoverId) set.add(e.targetAssetId) + if (e.targetAssetId === hoverId) set.add(e.sourceAssetId) + } + return set + }, [hoverId, edges]) + + function edgePath(e: DependencyEdge): string | null { + const s = layout.nodes.get(e.sourceAssetId) + const t = layout.nodes.get(e.targetAssetId) + if (!s || !t) return null + const sy = s.y + NODE_H / 2 + const ty = t.y + NODE_H / 2 + if (s.x === t.x) { + // Same column: arc out the right side. + const x = s.x + COL_W + const bulge = Math.min(80, 36 + Math.abs(ty - sy) * 0.08) + return `M ${x} ${sy} C ${x + bulge} ${sy}, ${x + bulge} ${ty}, ${x} ${ty}` + } + const leftToRight = s.x < t.x + const sx = leftToRight ? s.x + COL_W : s.x + const tx = leftToRight ? t.x : t.x + COL_W + const mid = (tx - sx) / 2 + return `M ${sx} ${sy} C ${sx + mid} ${sy}, ${tx - mid} ${ty}, ${tx} ${ty}` + } + + return ( +
+ {/* Lens picker + edge legend */} +
+
+ Lens + {lensOptions.map(({ key, label, icon: LIcon }) => ( + + ))} +
+
+ + + depends on + + + + integrates with + + + + aggregates + +
+
+ +
+
+ {/* Edge underlay */} + + + + + + + {edges.map((e) => { + const d = edgePath(e) + if (!d) return null + const active = hoverId !== null && (e.sourceAssetId === hoverId || e.targetAssetId === hoverId) + const dimmed = hoverId !== null && !active + return ( + + {`${e.sourceAssetName} ${e.dependencyType.replace(/_/g, ' ')} ${e.targetAssetName}${e.description ? ` — ${e.description}` : ''}`} + + ) + })} + + + {/* Product column headers */} + {layout.products.map((p, i) => ( +
+ {p.name} +
+ ))} + + {/* Asset nodes */} + {[...layout.nodes.values()].map(({ asset: a, x, y }) => { + const Icon = assetTypeIcons[a.type] + const dimmed = neighborsOfHover !== null && !neighborsOfHover.has(a.id) + return ( + setHoverId(a.id)} + onMouseLeave={() => setHoverId(null)} + onFocus={() => setHoverId(a.id)} + onBlur={() => setHoverId(null)} + className={cn( + 'absolute flex flex-col justify-center gap-0.5 rounded-md border border-border border-l-[3px] bg-background px-2.5 transition-all hover:border-accent/60 hover:shadow-md', + lensBorder(lens, a), + dimmed && 'opacity-35', + )} + style={{ left: x, top: y, width: COL_W, height: NODE_H }} + > + + + {a.name} + {a.currentVersion && ( + + {a.currentVersion} + + )} + + {lensDetail(lens, a)} + + ) + })} +
+
+

+ Drawn live from your asset inventory and dependency edges — hover an asset to see its blast radius, click through for its record. +

+
+ ) +} diff --git a/app/(dashboard)/assets/assets-client.tsx b/app/(dashboard)/assets/assets-client.tsx new file mode 100644 index 0000000..710829e --- /dev/null +++ b/app/(dashboard)/assets/assets-client.tsx @@ -0,0 +1,337 @@ +'use client' + +import { useMemo, useState } from 'react' +import Link from 'next/link' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { OwnerAvatars } from '@/components/owner-avatars' +import { + Box, Server, Library, Database, Globe, Search, + Map as MapIcon, LayoutGrid, List, FileCode2, BadgeCheck, Wrench, +} from 'lucide-react' +import { cn, formatDateShort } from '@/lib/utils' +import type { AssetInventory, AssetInventoryRow } from '@/lib/db/queries' +import type { AssetType } from '@/lib/types' +import { AssetMap } from './asset-map' + +export const assetTypeIcons: Record = { + app: Box, + service: Server, + library: Library, + datastore: Database, + platform: Globe, +} + +const assetTypeLabels: Record = { + app: 'App', + service: 'Service', + library: 'Library', + datastore: 'Datastore', + platform: 'Platform', +} + +export const healthStyles: Record = { + healthy: 'bg-accent/20 text-accent', + warning: 'bg-warning/20 text-warning', + critical: 'bg-destructive/20 text-destructive', +} + +const healthDot: Record = { + healthy: 'bg-accent', + warning: 'bg-warning', + critical: 'bg-destructive', +} + +function debtColor(score: number) { + return score < 25 ? 'bg-accent' : score < 50 ? 'bg-warning' : 'bg-destructive' +} + +type ViewMode = 'map' | 'grid' | 'table' + +export function AssetsClient({ inventory }: { inventory: AssetInventory }) { + const [view, setView] = useState('map') + const [query, setQuery] = useState('') + const [typeFilter, setTypeFilter] = useState('all') + const [healthFilter, setHealthFilter] = useState('all') + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + return inventory.assets.filter((a) => { + if (typeFilter !== 'all' && a.type !== typeFilter) return false + if (healthFilter !== 'all' && a.health !== healthFilter) return false + if (q && !a.name.toLowerCase().includes(q) && !a.tags.some((t) => t.toLowerCase().includes(q))) { + return false + } + return true + }) + }, [inventory.assets, query, typeFilter, healthFilter]) + + const filteredIds = useMemo(() => new Set(filtered.map((a) => a.id)), [filtered]) + const filteredEdges = useMemo( + () => inventory.edges.filter((e) => filteredIds.has(e.sourceAssetId) && filteredIds.has(e.targetAssetId)), + [inventory.edges, filteredIds], + ) + + const stats = useMemo(() => { + const s = { total: inventory.assets.length, warning: 0, critical: 0, debt: 0, activePlans: 0 } + for (const a of inventory.assets) { + if (a.health === 'warning') s.warning += 1 + if (a.health === 'critical') s.critical += 1 + s.debt += a.openDebtCount + s.activePlans += a.activePlanCount + } + return s + }, [inventory.assets]) + + const viewButtons: { mode: ViewMode; icon: typeof MapIcon; label: string }[] = [ + { mode: 'map', icon: MapIcon, label: 'Map' }, + { mode: 'grid', icon: LayoutGrid, label: 'Grid' }, + { mode: 'table', icon: List, label: 'Table' }, + ] + + return ( +
+ {/* Stats strip */} +
+ {stats.total} assets + + 0 ? 'text-destructive' : 'text-foreground')}> + {stats.critical} + {' '} + critical ·{' '} + 0 ? 'text-warning' : 'text-foreground')}> + {stats.warning} + {' '} + warning + + {stats.debt} open debt items + {stats.activePlans} active plan targets +
+ + {/* Toolbar */} +
+
+ {viewButtons.map(({ mode, icon: VIcon, label }) => ( + + ))} +
+
+ + setQuery(e.target.value)} + placeholder="Search name or tag…" + className="h-8 w-52 pl-8" + /> +
+ + +
+ + {filtered.length === 0 ? ( + + + No assets match. Adjust the filters, or add assets from a product page. + + + ) : view === 'map' ? ( + + ) : view === 'grid' ? ( + + ) : ( + + )} +
+ ) +} + +function AssetGrid({ assets }: { assets: AssetInventoryRow[] }) { + return ( +
+ {assets.map((a) => { + const Icon = assetTypeIcons[a.type] + return ( + + + +
+
+
+ +
+
+

+ {a.name} +

+

{a.productName}

+
+
+
+ {a.currentVersion && ( + {a.currentVersion} + )} + +
+
+ +
+ + {a.activePlanCount} active + + + {a.openDebtCount} debt + + + {a.capabilityCount} capabilities + +
+ +
+
+ Debt score + {a.effectiveDebtScore} +
+
+
+
+
+ +
+ {assetTypeLabels[a.type]} + +
+ + + + ) + })} +
+ ) +} + +type SortKey = 'name' | 'product' | 'debt' | 'plans' | 'shipped' + +function AssetTable({ assets }: { assets: AssetInventoryRow[] }) { + const [sort, setSort] = useState('name') + const [asc, setAsc] = useState(true) + + const sorted = useMemo(() => { + const rows = [...assets] + const dir = asc ? 1 : -1 + rows.sort((a, b) => { + switch (sort) { + case 'product': return dir * (a.productName.localeCompare(b.productName) || a.name.localeCompare(b.name)) + case 'debt': return dir * (a.effectiveDebtScore - b.effectiveDebtScore) + case 'plans': return dir * (a.activePlanCount - b.activePlanCount) + case 'shipped': return dir * (a.lastShippedAt ?? '').localeCompare(b.lastShippedAt ?? '') + default: return dir * a.name.localeCompare(b.name) + } + }) + return rows + }, [assets, sort, asc]) + + const header = (key: SortKey, label: string, className?: string) => ( + { + if (sort === key) setAsc(!asc) + else { setSort(key); setAsc(key === 'name' || key === 'product') } + }} + > + {label}{sort === key ? (asc ? ' ↑' : ' ↓') : ''} + + ) + + return ( + +
+ + + + {header('name', 'Asset')} + {header('product', 'Product')} + + + {header('debt', 'Debt', 'text-right')} + {header('plans', 'Active plans', 'text-right')} + {header('shipped', 'Last shipped')} + + + + + {sorted.map((a) => { + const Icon = assetTypeIcons[a.type] + return ( + + + + + + + + + + + ) + })} + +
HealthVersionOwners
+ + + {a.name} + + {a.productName} + + {a.health} + + {a.currentVersion ?? '—'}{a.effectiveDebtScore}{a.activePlanCount} + {a.lastShippedAt ? formatDateShort(a.lastShippedAt) : '—'} +
+
+
+ ) +} diff --git a/app/(dashboard)/assets/page.tsx b/app/(dashboard)/assets/page.tsx new file mode 100644 index 0000000..42f55cc --- /dev/null +++ b/app/(dashboard)/assets/page.tsx @@ -0,0 +1,31 @@ +import { authAdapter } from '@/lib/auth' +import { getAssetInventory } from '@/lib/db/queries' +import { getProductScope } from '@/lib/product-scope' +import { AssetsClient } from './assets-client' + +type Props = { + searchParams: Promise<{ product?: string }> +} + +export default async function AssetsPage({ searchParams }: Props) { + const user = await authAdapter.getUser() + if (!user) return null + + const { product: productParam } = await searchParams + const scope = await getProductScope() + const productId = productParam || scope || undefined + + const inventory = await getAssetInventory(user.id, { productId }) + + return ( +
+
+

Assets

+

+ Every asset across your architecture — mapped, measured, and one click from its record +

+
+ +
+ ) +} diff --git a/components/app-shell.tsx b/components/app-shell.tsx index c09d719..56aee06 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -33,6 +33,7 @@ import { Bell, Search, Building2, + Boxes, Layers, Plug, Rocket, @@ -55,6 +56,7 @@ const navigation = [ { name: 'Dashboard', href: '/', icon: LayoutDashboard }, { name: 'My Work', href: '/my-work', icon: UserCircle2 }, { name: 'Products', href: '/products', icon: Package }, + { name: 'Assets', href: '/assets', icon: Boxes }, { name: 'Work Items', href: '/work-items', icon: ClipboardList }, { name: 'Code Plans', href: '/plans', icon: FileCode2 }, { name: 'Releases', href: '/releases', icon: Rocket }, diff --git a/docs/app-spec.md b/docs/app-spec.md index d76fd5f..c87a90d 100644 --- a/docs/app-spec.md +++ b/docs/app-spec.md @@ -1,14 +1,14 @@ ## CodePlans App Spec -> **Status:** current implemented state as of **v0.4.4** (2026-08). For the target +> **Status:** current implemented state as of **v0.4.5** (2026-08). For the target > design and rationale, see `docs/specs/design-spec-v3.md` (all phases shipped), > `docs/specs/releases-and-asset-history-spec.md` (Phases A–D shipped), and > `docs/specs/asset-record-spec.md` (Phase A shipped; Phases B–C are the next -> tranche, v0.4.5+). +> tranche, v0.4.6+). ### Overview -CodePlans is a **code change coordination tool** for engineering teams. It organizes work around the hierarchy **Products → Assets → Code Plans → Tasks**, with **Work Items** (features, bugs, UX issues, tech debt) as the demand side linked many-to-many to code plans, per-asset **branch/PR tracking** on plans, **releases** grouping the plans that ship together (with per-asset version stamps and derived release notes), a per-asset **history timeline and design log**, a per-asset **record** (capabilities register graduated from delivered work), **asset dependencies** with impact analysis, pull-only **integrations** that mirror external tracker items into work items, and a 41-tool **MCP server** for AI coding agents. Users track technical debt, coordinate architectural changes, and measure team velocity. Deployed at `codeplans.ai`. Stack: Next.js 16 (App Router), Drizzle ORM, pluggable auth/DB (SQLite local / Supabase+Postgres cloud). +CodePlans is a **code change coordination tool** for engineering teams. It organizes work around the hierarchy **Products → Assets → Code Plans → Tasks**, with **Work Items** (features, bugs, UX issues, tech debt) as the demand side linked many-to-many to code plans, per-asset **branch/PR tracking** on plans, **releases** grouping the plans that ship together (with per-asset version stamps and derived release notes), a per-asset **history timeline and design log**, a per-asset **record** (capabilities register graduated from delivered work), a top-level **Asset Atlas** (live system map with health/debt/activity lenses, plus grid/table views), **asset dependencies** with impact analysis, pull-only **integrations** that mirror external tracker items into work items, and a 41-tool **MCP server** for AI coding agents. Users track technical debt, coordinate architectural changes, and measure team velocity. Deployed at `codeplans.ai`. Stack: Next.js 16 (App Router), Drizzle ORM, pluggable auth/DB (SQLite local / Supabase+Postgres cloud). --- @@ -193,6 +193,7 @@ Provenance columns (`source` default `native`, `connectionId`, `externalId/Key/U | `getRelease(id, userId)` | `ReleaseDetail` \| `null` | Org-scope guarded; assets & versions, attached plans with progress, derived work items, per-asset PR chips | | `getSuggestedReleaseAssets(releaseId)` | `ReleaseAssetChip[]` | Assets targeted by attached plans but not yet stamped on the release | | `getAssetRecord(assetId, userId)` | `AssetRecord` \| `null` | Org-scope guarded; capabilities (incl. tombstones), derived known issues (open bug/ux) & debt register (open tech_debt), graduation candidates (resolved feature/enhancement not yet graduated) | +| `getAssetInventory(userId, filters?)` | `AssetInventory` | Org-aware; optional `productId` scope. Every visible asset with effective debt score, open item/debt counts, active plan targets, capability count, latest shipped version stamp, owners — plus the dependency edges among them (edges leaving the scope are dropped) | --- @@ -243,7 +244,7 @@ Both redirect to `/` on success. #### Dashboard Layout (`/(dashboard)`) All routes share `AppShell`: 64px top header + 256px sidebar. Sidebar contains: - Product switcher dropdown — **wired**: "All Products" + per-product options; selection persisted in a cookie (`lib/product-scope-cookie.ts`) and scopes Dashboard, Products, Code Plans, Tasks, and Analytics -- Primary nav: Dashboard, My Work, Products, Work Items, Code Plans, Tasks, Analytics +- Primary nav: Dashboard, My Work, Products, Assets, Work Items, Code Plans, Releases, Tasks, Analytics - Secondary nav: Team, Integrations, Billing (hidden if `BILLING_ENABLED=false`), Settings - Org/user footer: org name + billing tier, links to Team/Billing/Settings @@ -292,6 +293,12 @@ Two tabs: **Assets** and **Code Plans**. --- +#### `/assets` — Asset Atlas (v0.4.5) +Top-level inventory of every visible asset (respects the global product scope + `?product=`), with a stats strip (totals, health breakdown, open debt, active plan targets), search (name/tag), and type/health filters. Three views: +- **Map** (default) — a system map drawn live from the inventory: products as columns, assets as nodes (type icon, name, shipped-version chip), `asset_dependencies` edges as curves (line style per dependency type: solid depends_on, dashed integrates_with, dotted aggregates, arrowheads at the target). **Lenses** recolor node accents and detail lines by Health, Debt (effective score thresholds 25/50), or Activity (active plan targets). Hovering an asset highlights its edges and neighbors and dims the rest (blast radius); click navigates to the asset. Hand-rolled deterministic layout (barycenter-ordered columns) — HTML nodes over an SVG underlay, no graph library. +- **Grid** — cards: type icon, product, health dot, version chip, active plans / open debt / capabilities counts, debt-score bar, owners. +- **Table** — sortable by name/product/debt/active plans/last shipped. + #### `/assets/[id]` — Asset Detail (v0.3.25) Header (type/health badges, current version chip from latest shipped release, repo/docs links, owners), summary cards (tech debt score with derived-vs-manual note, open work items, plan count), and tabs (v0.4.4 moved the auto-save description + notes cards into a default **Overview** tab so every tab's content starts above the fold): - **Overview** (default) — auto-save description + notes (ideation doc) cards diff --git a/docs/index.html b/docs/index.html index a46d1f7..679379f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -350,7 +350,7 @@
-
Open Source · v0.4.4 · Beta Coming Soon
+
Open Source · v0.4.5 · Beta Coming Soon

Coordinate changes across
your entire architecture

CodePlans gives engineering teams a shared view of what's changing, in which components, @@ -407,6 +407,7 @@

See it in action

+ @@ -476,6 +477,11 @@

Releases & Versions

Asset History & Design Log

Every asset carries a version-structured timeline: releases shipped, plans delivered, debt movement — plus curated design notes authored by your team or your coding agents.

+
+
🗺️
+

Asset Atlas

+

A system map drawn live from your inventory: products as columns, dependency edges between assets, and lenses that recolor the map by health, debt, or delivery activity. Hover to see any asset's blast radius.

+
🏛️

Asset Record

@@ -603,7 +609,7 @@

Releases & Asset History Spec

Design

Asset Record Spec

-

The capabilities register per asset shipped in v0.4.4. Next (v0.4.5+): agent-driven reconciliation against code and round-trip release publishing.

+

The capabilities register per asset shipped in v0.4.4. Next (v0.4.6+): agent-driven reconciliation against code and round-trip release publishing.

Read →
@@ -922,7 +928,8 @@

Current feature status

Asset history timeline, version ladder & design log (user + agent authored)Available AI drafting — release notes & design notes (feature-flagged)Available Asset Record — per-asset capabilities register with delivery lineage (v0.4.4)Available - Asset Record — agent reconciliation & round-trip publishing (v0.4.5+)Planned + Asset Atlas — live system map with health/debt/activity lenses (v0.4.5)Available + Asset Record — agent reconciliation & round-trip publishing (v0.4.6+)Planned AI-assisted effort estimationPlanned diff --git a/docs/screenshots/asset-map.png b/docs/screenshots/asset-map.png new file mode 100644 index 0000000..f9acd58 Binary files /dev/null and b/docs/screenshots/asset-map.png differ diff --git a/docs/specs/asset-record-spec.md b/docs/specs/asset-record-spec.md index 14a446a..fb7e423 100644 --- a/docs/specs/asset-record-spec.md +++ b/docs/specs/asset-record-spec.md @@ -334,14 +334,14 @@ known issues/debt); resolve-time graduation prompt + backfill checklist; *Exit criteria: a team can build and browse a receipted capabilities register with zero agent involvement.* -### Phase B — Reconciliation `v0.4.5` +### Phase B — Reconciliation `v0.4.6` `record_proposals` + review queue UI; `propose_record_change`, `list_record_proposals`, `resolve_record_proposal` MCP tools; the reconcile guide; AI-drafted capability descriptions on graduation (flagged, Phase D plumbing). *Exit criteria: an agent-run reconcile pass on this repo files sensible proposals end-to-end.* -### Phase C — Round-trip `v0.4.6` +### Phase C — Round-trip `v0.4.7` Publish-to-GitHub-Releases action on shipped releases; spec-audit section in the reconcile guide; Record freshness surfaced on the asset header (oldest `verifiedAt` drives a "record last verified" hint). *Exit criteria: the litmus diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 46d46a8..ed3770b 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -2140,3 +2140,161 @@ export async function getAssetRecord(assetId: string, userId: string): Promise
({ workItemId: r.id, title: r.title, type: r.type, resolvedAt: r.updatedAt.toISOString() })), } } + +// --------------------------------------------------------------------------- +// Asset inventory (the /assets Atlas view) +// --------------------------------------------------------------------------- + +export type AssetInventoryRow = { + id: string + name: string + type: AssetType + health: string + productId: string + productName: string + productSlug: string + tags: string[] + /** Manual override if set, else severity-weighted from open tech debt. */ + effectiveDebtScore: number + debtIsManual: boolean + openDebtCount: number + /** All open work items on the asset, tech debt included. */ + openItemCount: number + activePlanCount: number + capabilityCount: number + currentVersion?: string + lastShippedAt?: string + owners: AssetOwner[] +} + +export type AssetInventory = { + assets: AssetInventoryRow[] + /** Dependency edges where both endpoints are in `assets`. */ + edges: DependencyEdge[] +} + +/** + * Everything the Assets view needs in one shape: each visible asset with its + * delivery/debt/activity stats, plus the dependency edges among them (the + * system map). Optionally scoped to one product — edges crossing out of the + * scope are dropped so the map always matches the node set. + */ +export async function getAssetInventory( + userId: string, + filters: { productId?: string } = {}, +): Promise { + const productFilter = await productAccessWhere(userId) + const visibleProducts = await db + .select({ id: products.id, name: products.name, slug: products.slug }) + .from(products) + .where(filters.productId ? and(productFilter, eq(products.id, filters.productId)) : productFilter) + if (visibleProducts.length === 0) return { assets: [], edges: [] } + const productById = new Map(visibleProducts.map((p) => [p.id, p])) + const productIds = visibleProducts.map((p) => p.id) + + const assetRows = await db.query.assets.findMany({ + where: inArray(assets.productId, productIds), + orderBy: assets.name, + }) + const assetIds = assetRows.map((a) => a.id) + if (assetIds.length === 0) return { assets: [], edges: [] } + + const [owners, openItems, activePlans, capabilities, stamps, edgeRows] = await Promise.all([ + ownersByAsset(assetIds), + db + .select({ assetId: workItems.assetId, type: workItems.type, severity: workItems.severity }) + .from(workItems) + .where( + and( + inArray(workItems.assetId, assetIds), + inArray(workItems.status, ['open', 'planned', 'in_progress']), + ), + ), + db + .select({ assetId: codePlanAssets.assetId, count: sql`CAST(count(*) AS INTEGER)` }) + .from(codePlanAssets) + .innerJoin(codePlans, eq(codePlanAssets.codePlanId, codePlans.id)) + .where(and(inArray(codePlanAssets.assetId, assetIds), eq(codePlans.status, 'active'))) + .groupBy(codePlanAssets.assetId), + db + .select({ assetId: assetCapabilities.assetId, count: sql`CAST(count(*) AS INTEGER)` }) + .from(assetCapabilities) + .where(and(inArray(assetCapabilities.assetId, assetIds), eq(assetCapabilities.status, 'active'))) + .groupBy(assetCapabilities.assetId), + db + .select({ + assetId: releaseAssets.assetId, + version: releaseAssets.version, + shippedAt: releases.shippedAt, + }) + .from(releaseAssets) + .innerJoin(releases, eq(releaseAssets.releaseId, releases.id)) + .where(and(inArray(releaseAssets.assetId, assetIds), eq(releases.status, 'shipped'))) + .orderBy(desc(releases.shippedAt)), + db + .select({ + id: assetDependencies.id, + sourceAssetId: assetDependencies.sourceAssetId, + sourceAssetName: assets.name, + targetAssetId: assetDependencies.targetAssetId, + targetAssetName: sql`(select name from assets a2 where a2.id = ${assetDependencies.targetAssetId})`, + dependencyType: assetDependencies.dependencyType, + description: assetDependencies.description, + }) + .from(assetDependencies) + .innerJoin(assets, eq(assetDependencies.sourceAssetId, assets.id)) + .where(inArray(assetDependencies.sourceAssetId, assetIds)), + ]) + + const DEBT_WEIGHT: Record = { low: 3, medium: 8, high: 15, critical: 25 } + const openByAsset = new Map() + for (const r of openItems) { + if (!r.assetId) continue + const cur = openByAsset.get(r.assetId) ?? { total: 0, debt: 0, debtScore: 0 } + cur.total += 1 + if (r.type === 'tech_debt') { + cur.debt += 1 + cur.debtScore = Math.min(100, cur.debtScore + (DEBT_WEIGHT[r.severity] ?? 8)) + } + openByAsset.set(r.assetId, cur) + } + const planCountByAsset = new Map(activePlans.map((r) => [r.assetId, r.count])) + const capCountByAsset = new Map(capabilities.map((r) => [r.assetId, r.count])) + // stamps are ordered newest-shipped first — the first row per asset wins. + const latestStamp = new Map() + for (const s of stamps) { + if (latestStamp.has(s.assetId)) continue + latestStamp.set(s.assetId, { + version: s.version ?? undefined, + shippedAt: s.shippedAt?.toISOString(), + }) + } + + const idSet = new Set(assetIds) + return { + assets: assetRows.map((a) => { + const open = openByAsset.get(a.id) + const product = productById.get(a.productId)! + return { + id: a.id, + name: a.name, + type: a.type, + health: a.health, + productId: a.productId, + productName: product.name, + productSlug: product.slug, + tags: a.tags, + effectiveDebtScore: a.techDebtScore ?? open?.debtScore ?? 0, + debtIsManual: a.techDebtScore != null, + openDebtCount: open?.debt ?? 0, + openItemCount: open?.total ?? 0, + activePlanCount: planCountByAsset.get(a.id) ?? 0, + capabilityCount: capCountByAsset.get(a.id) ?? 0, + currentVersion: latestStamp.get(a.id)?.version, + lastShippedAt: latestStamp.get(a.id)?.shippedAt, + owners: owners.get(a.id) ?? [], + } + }), + edges: edgeRows.filter((e) => idSet.has(e.targetAssetId)), + } +} diff --git a/package.json b/package.json index 545237d..6bd965a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeplans", - "version": "0.4.4", + "version": "0.4.5", "description": "Manage and track coordinated changes across your software architecture.", "author": "Sai Prakash ", "homepage": "https://codeplans.ai", diff --git a/tests/lib/db/asset-inventory.test.ts b/tests/lib/db/asset-inventory.test.ts new file mode 100644 index 0000000..ee495ec --- /dev/null +++ b/tests/lib/db/asset-inventory.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest' +import { runMigrations, seedFixtures, clearTables, F } from '@/tests/helpers/db' +import { getAssetInventory } from '@/lib/db/queries' +import { createRelease, updateRelease, setReleaseAsset, graduateWorkItem } from '@/lib/db/mutations' +import { db } from '@/lib/db/index' +import { workItems, assetDependencies } from '@/lib/db/schema.sqlite' + +beforeAll(async () => { + await runMigrations() +}) + +beforeEach(async () => { + await seedFixtures() +}) + +afterEach(async () => { + await clearTables() +}) + +describe('getAssetInventory', () => { + it('returns per-asset stats: debt, open items, active plans, version, capabilities', async () => { + await (db as any).insert(workItems).values([ + { + id: 'wi-debt', productId: F.productShared, assetId: F.assetApi, + type: 'tech_debt', title: 'Retry logic', status: 'open', severity: 'high', tags: [], + }, + { + id: 'wi-bug', productId: F.productShared, assetId: F.assetApi, + type: 'bug', title: 'Crash', status: 'in_progress', severity: 'medium', tags: [], + }, + { + id: 'wi-done', productId: F.productShared, assetId: F.assetApi, + type: 'feature', title: 'Bulk export', status: 'resolved', severity: 'medium', tags: [], + }, + ]) + await graduateWorkItem('wi-done') + + const release = await createRelease({ productId: F.productShared, name: 'API v2' }, F.alice) + await setReleaseAsset(release.id, F.assetApi, { version: 'v2.0.0' }) + await updateRelease(release.id, { status: 'shipped' }) + + const inv = await getAssetInventory(F.alice) + expect(inv.assets.map((a) => a.name).sort()).toEqual(['API Service', 'Database']) + + const api = inv.assets.find((a) => a.id === F.assetApi)! + expect(api.openItemCount).toBe(2) + expect(api.openDebtCount).toBe(1) + expect(api.effectiveDebtScore).toBe(15) // one open high-severity item + expect(api.debtIsManual).toBe(false) + expect(api.activePlanCount).toBe(1) // planActive targets assetApi in fixtures + expect(api.capabilityCount).toBe(1) + expect(api.currentVersion).toBe('v2.0.0') + expect(api.lastShippedAt).toBeTruthy() + + const dbAsset = inv.assets.find((a) => a.id === F.assetDb)! + expect(dbAsset.activePlanCount).toBe(0) + expect(dbAsset.currentVersion).toBeUndefined() + }) + + it('returns dependency edges between visible assets', async () => { + await (db as any).insert(assetDependencies).values({ + id: 'edge-1', + sourceAssetId: F.assetApi, + targetAssetId: F.assetDb, + dependencyType: 'depends_on', + description: 'User store', + }) + const inv = await getAssetInventory(F.alice) + expect(inv.edges).toHaveLength(1) + expect(inv.edges[0].sourceAssetName).toBe('API Service') + expect(inv.edges[0].targetAssetName).toBe('Database') + }) + + it('scopes by product and enforces org access', async () => { + const scoped = await getAssetInventory(F.alice, { productId: F.productCarol }) + expect(scoped.assets).toHaveLength(0) + + const carol = await getAssetInventory(F.carol) + expect(carol.assets).toHaveLength(0) + }) +})