Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ CodePlans sits between your issue tracker and your architecture diagram:
| 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 |
| MCP server — 41 tools incl. product/asset/dependency management, releases, asset design notes & the asset record | ✅ Available |
| MCP server — 42 tools incl. product/asset/dependency management, model refactoring (move_asset), releases, asset design notes & the asset record | ✅ Available |
| Milestone-linked plans with mirrored tasks (mixed mode) | ✅ Available |
| PR auto-linking (plan-asset PR status refreshed on sync) | ✅ Available |
| Releases — delivery grouping with per-asset version stamps & derived release notes | ✅ Available |
Expand Down Expand Up @@ -413,7 +413,7 @@ Your agent can then read plans/work items/tech debt and (with a write-scope key)
- [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)
- [x] **v0.4.5 — Asset Atlas** (see [`docs/specs/asset-atlas-spec.md`](docs/specs/asset-atlas-spec.md)): 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 — Layers & model boundaries** (see [`docs/specs/layers-and-boundaries-spec.md`](docs/specs/layers-and-boundaries-spec.md)): asset `layer` field, Atlas layer columns for single-product systems, `move_asset` model refactoring, boundary guidance in MCP
- [x] **v0.4.6 — Layers & model boundaries** (see [`docs/specs/layers-and-boundaries-spec.md`](docs/specs/layers-and-boundaries-spec.md)): asset `layer` field with display-time type defaults, Atlas layer columns (auto for single-product scope), `move_asset` model refactoring, boundary rule + layer taxonomy in the MCP modeling guide (42 tools)
- [ ] **v0.4.7+ — 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)
Expand Down
3 changes: 3 additions & 0 deletions app/(dashboard)/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ export async function updateAssetAction(id: string, productSlug: string, formDat
const repositoryUrl = (formData.get('repositoryUrl') as string) || undefined
const repoPath = (formData.get('repoPath') as string) || undefined
const documentationUrl = (formData.get('documentationUrl') as string) || undefined
const layerRaw = formData.get('layer')

await updateAsset(id, {
name,
Expand All @@ -223,6 +224,8 @@ export async function updateAssetAction(id: string, productSlug: string, formDat
repositoryUrl,
repoPath,
documentationUrl,
// Absent field = form without the input (no change); blank = clear.
...(layerRaw !== null ? { layer: (layerRaw as string).trim() || null } : {}),
})

revalidatePath(`/products/${productSlug}`)
Expand Down
3 changes: 3 additions & 0 deletions app/(dashboard)/assets/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ export default async function AssetDetailPage({ params }: { params: Promise<{ id
<div className="flex items-center gap-3 flex-wrap mb-1">
<h1 className="text-2xl font-bold tracking-tight">{asset.name}</h1>
<Badge variant="secondary" className="text-xs">{assetTypeLabels[asset.type]}</Badge>
{asset.layer && (
<Badge variant="outline" className="text-xs capitalize">{asset.layer}</Badge>
)}
<Badge variant="secondary" className={cn('text-xs capitalize', healthStyles[asset.health])}>
{asset.health}
</Badge>
Expand Down
72 changes: 60 additions & 12 deletions app/(dashboard)/assets/asset-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
import { useMemo, useState } from 'react'
import Link from 'next/link'
import { Badge } from '@/components/ui/badge'
import { HeartPulse, Wrench, Activity } from 'lucide-react'
import { HeartPulse, Wrench, Activity, Package, Rows3 } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { AssetInventoryRow, DependencyEdge } from '@/lib/db/queries'
import { LAYER_TAXONOMY } from '@/lib/types'
import { assetTypeIcons } from './assets-client'

/**
Expand Down Expand Up @@ -54,22 +55,45 @@ const edgeDash: Record<DependencyEdge['dependencyType'], string | undefined> = {
}

type LaidOutNode = { asset: AssetInventoryRow; x: number; y: number }
type GroupBy = 'product' | 'layer'

/** Taxonomy layers in dependency-flow order, unknown layers appended alphabetically. */
function layerOrder(layers: Set<string>): string[] {
const known = LAYER_TAXONOMY.filter((l) => layers.has(l))
const unknown = [...layers].filter((l) => !(LAYER_TAXONOMY as readonly string[]).includes(l)).sort()
return [...known, ...unknown]
}

export function AssetMap({ assets, edges }: { assets: AssetInventoryRow[]; edges: DependencyEdge[] }) {
const [lens, setLens] = useState<Lens>('health')
const [hoverId, setHoverId] = useState<string | null>(null)
const productCount = useMemo(() => new Set(assets.map((a) => a.productId)).size, [assets])
// Single-product scope: the product axis degenerates to one column, so
// default to layer columns (layers-and-boundaries-spec §4). Toggle overrides.
const [groupByChoice, setGroupByChoice] = useState<GroupBy | null>(null)
const groupBy: GroupBy = groupByChoice ?? (productCount <= 1 ? 'layer' : 'product')

const layout = useMemo(() => {
// Column per product, in first-seen order.
// One column per group, in first-seen (product) or taxonomy (layer) order.
const byGroup = new Map<string, AssetInventoryRow[]>()
const productOrder: { id: string; name: string }[] = []
const byProduct = new Map<string, AssetInventoryRow[]>()
for (const a of assets) {
if (!byProduct.has(a.productId)) {
byProduct.set(a.productId, [])
productOrder.push({ id: a.productId, name: a.productName })
if (groupBy === 'product') {
for (const a of assets) {
if (!byGroup.has(a.productId)) {
byGroup.set(a.productId, [])
productOrder.push({ id: a.productId, name: a.productName })
}
byGroup.get(a.productId)!.push(a)
}
} else {
for (const a of assets) {
byGroup.set(a.effectiveLayer, [...(byGroup.get(a.effectiveLayer) ?? []), a])
}
for (const l of layerOrder(new Set(byGroup.keys()))) {
productOrder.push({ id: l, name: l })
}
byProduct.get(a.productId)!.push(a)
}
const byProduct = byGroup

// Two barycenter sweeps: order nodes within a column by the average row
// of their neighbors, so cross-column edges stay short and legible.
Expand Down Expand Up @@ -114,7 +138,7 @@ export function AssetMap({ assets, edges }: { assets: AssetInventoryRow[]; edges
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])
}, [assets, edges, groupBy])

const neighborsOfHover = useMemo(() => {
if (!hoverId) return null
Expand Down Expand Up @@ -149,8 +173,30 @@ export function AssetMap({ assets, edges }: { assets: AssetInventoryRow[]; edges
<div className="space-y-3">
{/* Lens picker + edge legend */}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground mr-1">Lens</span>
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-muted-foreground mr-1">Columns</span>
{(
[
{ key: 'product' as GroupBy, label: 'Product', icon: Package },
{ key: 'layer' as GroupBy, label: 'Layer', icon: Rows3 },
]
).map(({ key, label, icon: GIcon }) => (
<button
key={key}
type="button"
onClick={() => setGroupByChoice(key)}
className={cn(
'flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors',
groupBy === key
? 'border-accent/40 bg-accent/15 text-accent'
: 'border-border text-muted-foreground hover:text-foreground',
)}
>
<GIcon className="h-3 w-3" />
{label}
</button>
))}
<span className="text-xs text-muted-foreground mx-1">Lens</span>
{lensOptions.map(({ key, label, icon: LIcon }) => (
<button
key={key}
Expand Down Expand Up @@ -257,7 +303,9 @@ export function AssetMap({ assets, edges }: { assets: AssetInventoryRow[]; edges
</Badge>
)}
</span>
<span className="truncate text-xs text-muted-foreground capitalize">{lensDetail(lens, a)}</span>
<span className="truncate text-xs text-muted-foreground capitalize">
{groupBy === 'layer' && productCount > 1 ? a.productName : lensDetail(lens, a)}
</span>
</Link>
)
})}
Expand Down
18 changes: 16 additions & 2 deletions app/(dashboard)/assets/assets-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,16 @@ function AssetGrid({ assets }: { assets: AssetInventoryRow[] }) {
</div>

<div className="flex items-center justify-between">
<Badge variant="secondary" className="text-xs">{assetTypeLabels[a.type]}</Badge>
<div className="flex items-center gap-1.5">
<Badge variant="secondary" className="text-xs">{assetTypeLabels[a.type]}</Badge>
<Badge
variant="outline"
className={cn('text-xs capitalize', !a.layer && 'opacity-60')}
title={a.layer ? 'layer' : 'layer (default from type)'}
>
{a.effectiveLayer}
</Badge>
</div>
<OwnerAvatars owners={a.owners} />
</div>
</CardContent>
Expand All @@ -253,7 +262,7 @@ function AssetGrid({ assets }: { assets: AssetInventoryRow[] }) {
)
}

type SortKey = 'name' | 'product' | 'debt' | 'plans' | 'shipped'
type SortKey = 'name' | 'product' | 'layer' | 'debt' | 'plans' | 'shipped'

function AssetTable({ assets }: { assets: AssetInventoryRow[] }) {
const [sort, setSort] = useState<SortKey>('name')
Expand All @@ -265,6 +274,7 @@ function AssetTable({ assets }: { assets: AssetInventoryRow[] }) {
rows.sort((a, b) => {
switch (sort) {
case 'product': return dir * (a.productName.localeCompare(b.productName) || a.name.localeCompare(b.name))
case 'layer': return dir * (a.effectiveLayer.localeCompare(b.effectiveLayer) || 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 ?? '')
Expand Down Expand Up @@ -294,6 +304,7 @@ function AssetTable({ assets }: { assets: AssetInventoryRow[] }) {
<tr className="border-b border-border text-xs text-muted-foreground">
{header('name', 'Asset')}
{header('product', 'Product')}
{header('layer', 'Layer')}
<th className="px-4 py-2.5 text-left font-medium">Health</th>
<th className="px-4 py-2.5 text-left font-medium">Version</th>
{header('debt', 'Debt', 'text-right')}
Expand All @@ -314,6 +325,9 @@ function AssetTable({ assets }: { assets: AssetInventoryRow[] }) {
</Link>
</td>
<td className="px-4 py-2.5 text-muted-foreground">{a.productName}</td>
<td className={cn('px-4 py-2.5 capitalize', a.layer ? 'text-foreground' : 'text-muted-foreground')}>
{a.effectiveLayer}
</td>
<td className="px-4 py-2.5">
<Badge variant="secondary" className={cn('text-xs capitalize', healthStyles[a.health])}>
{a.health}
Expand Down
16 changes: 13 additions & 3 deletions app/(dashboard)/products/[slug]/assets-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -392,9 +392,19 @@ function AssetEditor({
<Input id="ae-path" name="repoPath" defaultValue={asset.repoPath ?? ''} placeholder="apps/web" />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="ae-docs" className="text-xs">Documentation URL</Label>
<Input id="ae-docs" name="documentationUrl" type="url" defaultValue={asset.documentationUrl ?? ''} />
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="ae-docs" className="text-xs">Documentation URL</Label>
<Input id="ae-docs" name="documentationUrl" type="url" defaultValue={asset.documentationUrl ?? ''} />
</div>
<div className="space-y-1.5">
<Label htmlFor="ae-layer" className="text-xs">Layer (blank = default from type)</Label>
<Input id="ae-layer" name="layer" defaultValue={asset.layer ?? ''} list="asset-layer-taxonomy" placeholder="backend" />
<datalist id="asset-layer-taxonomy">
<option value="edge" /><option value="frontend" /><option value="backend" />
<option value="domain" /><option value="data" /><option value="infra" /><option value="shared" />
</datalist>
</div>
</div>
<p className="text-xs text-muted-foreground">{isPending ? 'Saving…' : 'Changes save automatically'}</p>
</form>
Expand Down
Loading
Loading