@prmichaelsen/acp-visualizer 0.1.8 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -2
- package/src/components/BurndownChart.tsx +148 -0
- package/src/components/DependencyGraph.tsx +196 -0
- package/src/components/FilterBar.tsx +1 -0
- package/src/components/GitHubInput.tsx +65 -0
- package/src/components/MilestoneGantt.tsx +150 -0
- package/src/components/MilestoneKanban.tsx +117 -0
- package/src/components/ProjectSelector.tsx +66 -0
- package/src/components/Sidebar.tsx +26 -3
- package/src/components/StatusBadge.tsx +2 -0
- package/src/components/StatusDot.tsx +1 -0
- package/src/components/ViewToggle.tsx +25 -22
- package/src/contexts/ProgressContext.tsx +22 -0
- package/src/lib/types.ts +1 -1
- package/src/lib/yaml-loader-real.spec.ts +54 -1
- package/src/lib/yaml-loader.ts +134 -5
- package/src/routeTree.gen.ts +34 -3
- package/src/routes/__root.tsx +88 -19
- package/src/routes/activity.tsx +97 -0
- package/src/routes/index.tsx +6 -1
- package/src/routes/milestones.tsx +27 -10
- package/src/routes/search.tsx +2 -1
- package/src/routes/tasks.tsx +2 -1
- package/src/server.ts +13 -0
- package/src/services/github.service.ts +51 -0
- package/src/services/progress-database.service.ts +17 -39
- package/src/services/projects.service.ts +69 -0
- package/vite.config.ts +7 -2
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { StatusBadge } from './StatusBadge'
|
|
2
|
+
import { ProgressBar } from './ProgressBar'
|
|
3
|
+
import { TaskList } from './TaskList'
|
|
4
|
+
import type { Milestone, Task, Status } from '../lib/types'
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { ChevronDown, ChevronRight } from 'lucide-react'
|
|
7
|
+
|
|
8
|
+
const columns: Array<{ status: Status; label: string; color: string }> = [
|
|
9
|
+
{ status: 'not_started', label: 'Not Started', color: 'border-gray-600' },
|
|
10
|
+
{ status: 'in_progress', label: 'In Progress', color: 'border-blue-500' },
|
|
11
|
+
{ status: 'completed', label: 'Completed', color: 'border-green-500' },
|
|
12
|
+
{ status: 'wont_do', label: "Won't Do", color: 'border-yellow-500' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
interface MilestoneKanbanProps {
|
|
16
|
+
milestones: Milestone[]
|
|
17
|
+
tasks: Record<string, Task[]>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function KanbanCard({
|
|
21
|
+
milestone,
|
|
22
|
+
tasks,
|
|
23
|
+
}: {
|
|
24
|
+
milestone: Milestone
|
|
25
|
+
tasks: Task[]
|
|
26
|
+
}) {
|
|
27
|
+
const [expanded, setExpanded] = useState(false)
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="bg-gray-900/50 border border-gray-800 rounded-lg p-3 hover:border-gray-700 transition-colors">
|
|
31
|
+
<div className="flex items-start justify-between gap-2 mb-2">
|
|
32
|
+
<h4 className="text-sm font-medium leading-tight">{milestone.name}</h4>
|
|
33
|
+
</div>
|
|
34
|
+
<div className="flex items-center gap-2 mb-2">
|
|
35
|
+
<div className="flex-1">
|
|
36
|
+
<ProgressBar value={milestone.progress} size="sm" />
|
|
37
|
+
</div>
|
|
38
|
+
<span className="text-xs text-gray-500 font-mono">
|
|
39
|
+
{milestone.tasks_completed}/{milestone.tasks_total}
|
|
40
|
+
</span>
|
|
41
|
+
</div>
|
|
42
|
+
{milestone.notes && (
|
|
43
|
+
<p className="text-xs text-gray-500 truncate mb-2">{milestone.notes}</p>
|
|
44
|
+
)}
|
|
45
|
+
{tasks.length > 0 && (
|
|
46
|
+
<button
|
|
47
|
+
onClick={() => setExpanded(!expanded)}
|
|
48
|
+
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
|
49
|
+
>
|
|
50
|
+
{expanded ? (
|
|
51
|
+
<ChevronDown className="w-3 h-3" />
|
|
52
|
+
) : (
|
|
53
|
+
<ChevronRight className="w-3 h-3" />
|
|
54
|
+
)}
|
|
55
|
+
{tasks.length} task{tasks.length !== 1 ? 's' : ''}
|
|
56
|
+
</button>
|
|
57
|
+
)}
|
|
58
|
+
{expanded && (
|
|
59
|
+
<div className="mt-2 border-t border-gray-800 pt-2">
|
|
60
|
+
<TaskList tasks={tasks} />
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function MilestoneKanban({ milestones, tasks }: MilestoneKanbanProps) {
|
|
68
|
+
if (milestones.length === 0) {
|
|
69
|
+
return <p className="text-gray-600 text-sm">No milestones</p>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Only show columns that have items, except always show the core 3
|
|
73
|
+
const coreStatuses = new Set<Status>(['not_started', 'in_progress', 'completed'])
|
|
74
|
+
const activeColumns = columns.filter(
|
|
75
|
+
(col) =>
|
|
76
|
+
coreStatuses.has(col.status) ||
|
|
77
|
+
milestones.some((m) => m.status === col.status),
|
|
78
|
+
)
|
|
79
|
+
const gridCols =
|
|
80
|
+
activeColumns.length <= 3
|
|
81
|
+
? 'grid-cols-3'
|
|
82
|
+
: 'grid-cols-4'
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className={`grid ${gridCols} gap-4 min-h-[300px]`}>
|
|
86
|
+
{activeColumns.map((col) => {
|
|
87
|
+
const items = milestones.filter((m) => m.status === col.status)
|
|
88
|
+
return (
|
|
89
|
+
<div key={col.status} className="flex flex-col">
|
|
90
|
+
<div
|
|
91
|
+
className={`flex items-center gap-2 pb-3 mb-3 border-b-2 ${col.color}`}
|
|
92
|
+
>
|
|
93
|
+
<h3 className="text-sm font-medium text-gray-300">{col.label}</h3>
|
|
94
|
+
<span className="text-xs text-gray-500 bg-gray-800 px-1.5 py-0.5 rounded-full">
|
|
95
|
+
{items.length}
|
|
96
|
+
</span>
|
|
97
|
+
</div>
|
|
98
|
+
<div className="space-y-2 flex-1">
|
|
99
|
+
{items.map((m) => (
|
|
100
|
+
<KanbanCard
|
|
101
|
+
key={m.id}
|
|
102
|
+
milestone={m}
|
|
103
|
+
tasks={tasks[m.id] || []}
|
|
104
|
+
/>
|
|
105
|
+
))}
|
|
106
|
+
{items.length === 0 && (
|
|
107
|
+
<p className="text-xs text-gray-700 text-center py-4">
|
|
108
|
+
No milestones
|
|
109
|
+
</p>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
})}
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { ChevronDown } from 'lucide-react'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import type { AcpProject } from '../services/projects.service'
|
|
4
|
+
|
|
5
|
+
interface ProjectSelectorProps {
|
|
6
|
+
projects: AcpProject[]
|
|
7
|
+
currentProject: string | null
|
|
8
|
+
onSelect: (projectId: string) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ProjectSelector({
|
|
12
|
+
projects,
|
|
13
|
+
currentProject,
|
|
14
|
+
onSelect,
|
|
15
|
+
}: ProjectSelectorProps) {
|
|
16
|
+
const [open, setOpen] = useState(false)
|
|
17
|
+
|
|
18
|
+
const available = projects.filter((p) => p.hasProgress)
|
|
19
|
+
const current = available.find((p) => p.id === currentProject)
|
|
20
|
+
|
|
21
|
+
if (available.length <= 1) return null
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="relative">
|
|
25
|
+
<button
|
|
26
|
+
onClick={() => setOpen(!open)}
|
|
27
|
+
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-gray-400 bg-gray-900 border border-gray-800 rounded-md hover:text-gray-300 hover:border-gray-600 transition-colors"
|
|
28
|
+
>
|
|
29
|
+
<span className="truncate">
|
|
30
|
+
{current?.id || 'Select project'}
|
|
31
|
+
</span>
|
|
32
|
+
<ChevronDown className={`w-3 h-3 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
|
|
33
|
+
</button>
|
|
34
|
+
|
|
35
|
+
{open && (
|
|
36
|
+
<>
|
|
37
|
+
<div
|
|
38
|
+
className="fixed inset-0 z-40"
|
|
39
|
+
onClick={() => setOpen(false)}
|
|
40
|
+
/>
|
|
41
|
+
<div className="absolute left-0 right-0 top-full mt-1 z-50 bg-gray-900 border border-gray-700 rounded-lg shadow-xl overflow-hidden max-h-64 overflow-y-auto">
|
|
42
|
+
{available.map((p) => (
|
|
43
|
+
<button
|
|
44
|
+
key={p.id}
|
|
45
|
+
onClick={() => {
|
|
46
|
+
onSelect(p.id)
|
|
47
|
+
setOpen(false)
|
|
48
|
+
}}
|
|
49
|
+
className={`w-full text-left px-3 py-2 text-xs transition-colors ${
|
|
50
|
+
p.id === currentProject
|
|
51
|
+
? 'bg-gray-800 text-gray-100'
|
|
52
|
+
: 'text-gray-400 hover:bg-gray-800/50 hover:text-gray-200'
|
|
53
|
+
}`}
|
|
54
|
+
>
|
|
55
|
+
<div className="font-medium truncate">{p.id}</div>
|
|
56
|
+
{p.description && p.description !== 'No description' && (
|
|
57
|
+
<div className="text-gray-600 truncate mt-0.5">{p.description}</div>
|
|
58
|
+
)}
|
|
59
|
+
</button>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
</>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import { Link, useRouterState } from '@tanstack/react-router'
|
|
2
|
-
import { LayoutDashboard, Flag, CheckSquare, Search } from 'lucide-react'
|
|
2
|
+
import { LayoutDashboard, Flag, CheckSquare, Clock, Search } from 'lucide-react'
|
|
3
|
+
import { ProjectSelector } from './ProjectSelector'
|
|
4
|
+
import { GitHubInput } from './GitHubInput'
|
|
5
|
+
import type { AcpProject } from '../services/projects.service'
|
|
3
6
|
|
|
4
7
|
const navItems = [
|
|
5
8
|
{ to: '/' as const, icon: LayoutDashboard, label: 'Overview' },
|
|
6
9
|
{ to: '/milestones' as const, icon: Flag, label: 'Milestones' },
|
|
7
10
|
{ to: '/tasks' as const, icon: CheckSquare, label: 'Tasks' },
|
|
11
|
+
{ to: '/activity' as const, icon: Clock, label: 'Activity' },
|
|
8
12
|
]
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
interface SidebarProps {
|
|
15
|
+
projects?: AcpProject[]
|
|
16
|
+
currentProject?: string | null
|
|
17
|
+
onProjectSelect?: (projectId: string) => void
|
|
18
|
+
onGitHubLoad?: (owner: string, repo: string) => Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Sidebar({ projects = [], currentProject = null, onProjectSelect, onGitHubLoad }: SidebarProps) {
|
|
11
22
|
const location = useRouterState({ select: (s) => s.location })
|
|
12
23
|
|
|
13
24
|
return (
|
|
@@ -17,6 +28,15 @@ export function Sidebar() {
|
|
|
17
28
|
ACP Visualizer
|
|
18
29
|
</span>
|
|
19
30
|
</div>
|
|
31
|
+
{projects.length > 1 && onProjectSelect && (
|
|
32
|
+
<div className="px-3 pt-3">
|
|
33
|
+
<ProjectSelector
|
|
34
|
+
projects={projects}
|
|
35
|
+
currentProject={currentProject}
|
|
36
|
+
onSelect={onProjectSelect}
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
)}
|
|
20
40
|
<div className="flex-1 py-2">
|
|
21
41
|
{navItems.map((item) => {
|
|
22
42
|
const isActive =
|
|
@@ -40,7 +60,7 @@ export function Sidebar() {
|
|
|
40
60
|
)
|
|
41
61
|
})}
|
|
42
62
|
</div>
|
|
43
|
-
<div className="p-3 border-t border-gray-800">
|
|
63
|
+
<div className="p-3 border-t border-gray-800 space-y-2">
|
|
44
64
|
<Link
|
|
45
65
|
to="/search"
|
|
46
66
|
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-500 bg-gray-900 border border-gray-800 rounded-md hover:text-gray-300 hover:border-gray-600 transition-colors"
|
|
@@ -48,6 +68,9 @@ export function Sidebar() {
|
|
|
48
68
|
<Search className="w-4 h-4" />
|
|
49
69
|
Search...
|
|
50
70
|
</Link>
|
|
71
|
+
{onGitHubLoad && (
|
|
72
|
+
<GitHubInput onLoad={onGitHubLoad} />
|
|
73
|
+
)}
|
|
51
74
|
</div>
|
|
52
75
|
</nav>
|
|
53
76
|
)
|
|
@@ -4,12 +4,14 @@ const statusStyles: Record<Status, string> = {
|
|
|
4
4
|
completed: 'bg-green-500/15 text-green-400 border-green-500/20',
|
|
5
5
|
in_progress: 'bg-blue-500/15 text-blue-400 border-blue-500/20',
|
|
6
6
|
not_started: 'bg-gray-500/15 text-gray-500 border-gray-500/20',
|
|
7
|
+
wont_do: 'bg-yellow-500/15 text-yellow-500 border-yellow-500/20',
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
const statusLabels: Record<Status, string> = {
|
|
10
11
|
completed: 'Completed',
|
|
11
12
|
in_progress: 'In Progress',
|
|
12
13
|
not_started: 'Not Started',
|
|
14
|
+
wont_do: "Won't Do",
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export function StatusBadge({ status }: { status: Status }) {
|
|
@@ -4,6 +4,7 @@ const dotStyles: Record<Status, { symbol: string; color: string }> = {
|
|
|
4
4
|
completed: { symbol: '✓', color: 'text-green-400' },
|
|
5
5
|
in_progress: { symbol: '●', color: 'text-blue-400' },
|
|
6
6
|
not_started: { symbol: '○', color: 'text-gray-500' },
|
|
7
|
+
wont_do: { symbol: '✕', color: 'text-yellow-500' },
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export function StatusDot({ status }: { status: Status }) {
|
|
@@ -1,31 +1,34 @@
|
|
|
1
|
+
export type ViewMode = 'table' | 'tree' | 'kanban' | 'gantt' | 'graph'
|
|
2
|
+
|
|
1
3
|
interface ViewToggleProps {
|
|
2
|
-
value:
|
|
3
|
-
onChange: (view:
|
|
4
|
+
value: ViewMode
|
|
5
|
+
onChange: (view: ViewMode) => void
|
|
4
6
|
}
|
|
5
7
|
|
|
8
|
+
const views: Array<{ id: ViewMode; label: string }> = [
|
|
9
|
+
{ id: 'table', label: 'Table' },
|
|
10
|
+
{ id: 'tree', label: 'Tree' },
|
|
11
|
+
{ id: 'kanban', label: 'Kanban' },
|
|
12
|
+
{ id: 'gantt', label: 'Gantt' },
|
|
13
|
+
{ id: 'graph', label: 'Graph' },
|
|
14
|
+
]
|
|
15
|
+
|
|
6
16
|
export function ViewToggle({ value, onChange }: ViewToggleProps) {
|
|
7
17
|
return (
|
|
8
18
|
<div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-0.5">
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
value === 'tree'
|
|
23
|
-
? 'bg-gray-700 text-gray-100'
|
|
24
|
-
: 'text-gray-500 hover:text-gray-300'
|
|
25
|
-
}`}
|
|
26
|
-
>
|
|
27
|
-
Tree
|
|
28
|
-
</button>
|
|
19
|
+
{views.map((v) => (
|
|
20
|
+
<button
|
|
21
|
+
key={v.id}
|
|
22
|
+
onClick={() => onChange(v.id)}
|
|
23
|
+
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
|
24
|
+
value === v.id
|
|
25
|
+
? 'bg-gray-700 text-gray-100'
|
|
26
|
+
: 'text-gray-500 hover:text-gray-300'
|
|
27
|
+
}`}
|
|
28
|
+
>
|
|
29
|
+
{v.label}
|
|
30
|
+
</button>
|
|
31
|
+
))}
|
|
29
32
|
</div>
|
|
30
33
|
)
|
|
31
34
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react'
|
|
2
|
+
import type { ProgressData } from '../lib/types'
|
|
3
|
+
|
|
4
|
+
const ProgressContext = createContext<ProgressData | null>(null)
|
|
5
|
+
|
|
6
|
+
export function ProgressProvider({
|
|
7
|
+
data,
|
|
8
|
+
children,
|
|
9
|
+
}: {
|
|
10
|
+
data: ProgressData | null
|
|
11
|
+
children: React.ReactNode
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<ProgressContext.Provider value={data}>
|
|
15
|
+
{children}
|
|
16
|
+
</ProgressContext.Provider>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useProgressData(): ProgressData | null {
|
|
21
|
+
return useContext(ProgressContext)
|
|
22
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Status values for milestones and tasks */
|
|
2
|
-
export type Status = 'completed' | 'in_progress' | 'not_started'
|
|
2
|
+
export type Status = 'completed' | 'in_progress' | 'not_started' | 'wont_do'
|
|
3
3
|
|
|
4
4
|
/** Unknown properties from agent-maintained YAML are preserved here */
|
|
5
5
|
export type ExtraFields = Record<string, unknown>
|
|
@@ -37,11 +37,64 @@ describe('parseProgressYaml with real files', () => {
|
|
|
37
37
|
expect(totalTasks).toBeGreaterThan(0)
|
|
38
38
|
|
|
39
39
|
for (const m of result.milestones) {
|
|
40
|
-
expect(['completed', 'in_progress', 'not_started']).toContain(m.status)
|
|
40
|
+
expect(['completed', 'in_progress', 'not_started', 'wont_do']).toContain(m.status)
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
console.log(
|
|
44
44
|
`Parsed: ${result.milestones.length} milestones, ${totalTasks} tasks, ${result.recent_work.length} work entries`,
|
|
45
45
|
)
|
|
46
46
|
})
|
|
47
|
+
|
|
48
|
+
it('parses agentbase.me progress.yaml with inline milestones and standalone tasks', () => {
|
|
49
|
+
const path = '/home/prmichaelsen/.acp/projects/agentbase.me/agent/progress.yaml'
|
|
50
|
+
let raw: string
|
|
51
|
+
try {
|
|
52
|
+
raw = readFileSync(path, 'utf-8')
|
|
53
|
+
} catch {
|
|
54
|
+
console.log('Skipping — agentbase.me progress.yaml not found')
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const result = parseProgressYaml(raw)
|
|
59
|
+
|
|
60
|
+
// Should find project metadata
|
|
61
|
+
expect(result.project.name).toBe('agentbase.me')
|
|
62
|
+
|
|
63
|
+
// Should find inline milestones (milestone_1_firebase_analytics, etc.)
|
|
64
|
+
expect(result.milestones.length).toBeGreaterThan(0)
|
|
65
|
+
const milestoneIds = result.milestones.map((m) => m.id)
|
|
66
|
+
expect(milestoneIds).toContain('M1') // from milestone_1_firebase_analytics
|
|
67
|
+
expect(milestoneIds).toContain('M47') // from milestone_47_agent_memory_system
|
|
68
|
+
|
|
69
|
+
// Should find standard milestones too
|
|
70
|
+
expect(milestoneIds).toContain('M19') // from milestones: array
|
|
71
|
+
expect(milestoneIds).toContain('M21')
|
|
72
|
+
|
|
73
|
+
// Should find tasks from inline milestones
|
|
74
|
+
const m1Tasks = result.tasks['M1'] || []
|
|
75
|
+
expect(m1Tasks.length).toBeGreaterThan(0)
|
|
76
|
+
// Task 79 has wont_do status
|
|
77
|
+
const wontDoTask = m1Tasks.find((t) => String(t.id) === '79')
|
|
78
|
+
if (wontDoTask) {
|
|
79
|
+
expect(wontDoTask.status).toBe('wont_do')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Should find standalone/unassigned tasks
|
|
83
|
+
const unassigned = result.tasks['_unassigned'] || []
|
|
84
|
+
expect(unassigned.length).toBeGreaterThan(0)
|
|
85
|
+
// Synthetic milestone should exist
|
|
86
|
+
expect(milestoneIds).toContain('_unassigned')
|
|
87
|
+
|
|
88
|
+
const totalTasks = Object.values(result.tasks).reduce(
|
|
89
|
+
(sum, ts) => sum + ts.length,
|
|
90
|
+
0,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
// Should collect next_steps from next_immediate_steps too
|
|
94
|
+
expect(result.next_steps.length).toBeGreaterThan(0)
|
|
95
|
+
|
|
96
|
+
console.log(
|
|
97
|
+
`Parsed agentbase.me: ${result.milestones.length} milestones, ${totalTasks} tasks, ${unassigned.length} unassigned`,
|
|
98
|
+
)
|
|
99
|
+
})
|
|
47
100
|
})
|
package/src/lib/yaml-loader.ts
CHANGED
|
@@ -47,6 +47,7 @@ function normalizeStatus(value: unknown): Status {
|
|
|
47
47
|
.replace(/[\s-]/g, '_')
|
|
48
48
|
if (s === 'completed' || s === 'done' || s === 'complete') return 'completed'
|
|
49
49
|
if (s === 'in_progress' || s === 'active' || s === 'wip' || s === 'started') return 'in_progress'
|
|
50
|
+
if (s === 'wont_do' || s === 'won_t_do' || s === 'skipped' || s === 'cancelled' || s === 'canceled') return 'wont_do'
|
|
50
51
|
return 'not_started'
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -56,7 +57,9 @@ function safeString(value: unknown, fallback = ''): string {
|
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
function safeNumber(value: unknown, fallback = 0): number {
|
|
59
|
-
|
|
60
|
+
// Strip trailing '%' (e.g. "100%" → "100") before parsing
|
|
61
|
+
const cleaned = typeof value === 'string' ? value.replace(/%$/, '') : value
|
|
62
|
+
const n = Number(cleaned)
|
|
60
63
|
return Number.isFinite(n) ? n : fallback
|
|
61
64
|
}
|
|
62
65
|
|
|
@@ -267,6 +270,84 @@ const EMPTY_PROGRESS_DATA: ProgressData = {
|
|
|
267
270
|
progress: { planning: 0, implementation: 0, overall: 0 },
|
|
268
271
|
}
|
|
269
272
|
|
|
273
|
+
// --- Inline milestone extraction ---
|
|
274
|
+
// Some progress.yaml files use top-level keys like `milestone_1_firebase_analytics:`
|
|
275
|
+
// containing milestone metadata + inline `tasks:` arrays.
|
|
276
|
+
|
|
277
|
+
const INLINE_MILESTONE_PATTERN = /^milestone_(\d+)_/
|
|
278
|
+
|
|
279
|
+
function extractInlineMilestones(d: Record<string, unknown>): {
|
|
280
|
+
milestones: Milestone[]
|
|
281
|
+
tasks: Record<string, Task[]>
|
|
282
|
+
} {
|
|
283
|
+
const milestones: Milestone[] = []
|
|
284
|
+
const tasks: Record<string, Task[]> = {}
|
|
285
|
+
|
|
286
|
+
for (const [key, value] of Object.entries(d)) {
|
|
287
|
+
const match = key.match(INLINE_MILESTONE_PATTERN)
|
|
288
|
+
if (!match) continue
|
|
289
|
+
const obj = asRecord(value)
|
|
290
|
+
// Extract milestone ID from the name field (e.g. "M1 - ...") or synthesize from key
|
|
291
|
+
const nameStr = safeString(obj.name)
|
|
292
|
+
const idFromName = nameStr.match(/^(M\d+)\b/)?.[1]
|
|
293
|
+
const milestoneId = idFromName || `M${match[1]}`
|
|
294
|
+
|
|
295
|
+
const milestone = normalizeMilestone(
|
|
296
|
+
{ ...obj, id: milestoneId },
|
|
297
|
+
milestones.length,
|
|
298
|
+
)
|
|
299
|
+
milestones.push(milestone)
|
|
300
|
+
|
|
301
|
+
// Extract inline tasks if present
|
|
302
|
+
if (Array.isArray(obj.tasks)) {
|
|
303
|
+
tasks[milestoneId] = obj.tasks.map((t, i) =>
|
|
304
|
+
normalizeTask(t, milestoneId, i),
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { milestones, tasks }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// --- Standalone / unassigned task extraction ---
|
|
313
|
+
|
|
314
|
+
function extractLooseTasks(
|
|
315
|
+
d: Record<string, unknown>,
|
|
316
|
+
keys: string[],
|
|
317
|
+
): Task[] {
|
|
318
|
+
const result: Task[] = []
|
|
319
|
+
for (const key of keys) {
|
|
320
|
+
const arr = d[key]
|
|
321
|
+
if (Array.isArray(arr)) {
|
|
322
|
+
result.push(
|
|
323
|
+
...arr.map((t, i) => normalizeTask(t, '_unassigned', result.length + i)),
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return result
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// --- next_steps from multiple sources ---
|
|
331
|
+
|
|
332
|
+
function collectNextSteps(d: Record<string, unknown>): string[] {
|
|
333
|
+
const steps = normalizeStringArray(d.next_steps)
|
|
334
|
+
// next_immediate_steps may be a structured object with sub-keys or an array
|
|
335
|
+
const immediate = d.next_immediate_steps
|
|
336
|
+
if (immediate) {
|
|
337
|
+
if (Array.isArray(immediate)) {
|
|
338
|
+
steps.push(...immediate.map(String))
|
|
339
|
+
} else if (typeof immediate === 'object' && immediate !== null) {
|
|
340
|
+
// Structured object like { ready_to_implement: [...], security: [...] }
|
|
341
|
+
for (const arr of Object.values(immediate)) {
|
|
342
|
+
if (Array.isArray(arr)) {
|
|
343
|
+
steps.push(...arr.map(String))
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return steps
|
|
349
|
+
}
|
|
350
|
+
|
|
270
351
|
export function parseProgressYaml(raw: string): ProgressData {
|
|
271
352
|
try {
|
|
272
353
|
// json: true allows duplicated keys (last wins) — common in agent-maintained YAML
|
|
@@ -276,14 +357,62 @@ export function parseProgressYaml(raw: string): ProgressData {
|
|
|
276
357
|
}
|
|
277
358
|
const d = doc as Record<string, unknown>
|
|
278
359
|
|
|
279
|
-
|
|
360
|
+
// Standard milestones array
|
|
361
|
+
const standardMilestones = normalizeMilestones(d.milestones)
|
|
362
|
+
|
|
363
|
+
// Inline milestone_N_* top-level keys
|
|
364
|
+
const inline = extractInlineMilestones(d)
|
|
365
|
+
|
|
366
|
+
// Merge: inline milestones first (they tend to be older/lower-numbered),
|
|
367
|
+
// then standard milestones. Deduplicate by ID (standard wins).
|
|
368
|
+
const seenIds = new Set<string>()
|
|
369
|
+
const allMilestones: Milestone[] = []
|
|
370
|
+
for (const m of [...inline.milestones, ...standardMilestones]) {
|
|
371
|
+
if (!seenIds.has(m.id)) {
|
|
372
|
+
seenIds.add(m.id)
|
|
373
|
+
allMilestones.push(m)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Standard tasks (from top-level `tasks:` key — last-wins with json: true)
|
|
378
|
+
const standardTasks = normalizeTasks(d.tasks, allMilestones)
|
|
379
|
+
|
|
380
|
+
// Merge inline tasks with standard tasks (standard wins on conflict)
|
|
381
|
+
const allTasks: Record<string, Task[]> = { ...inline.tasks }
|
|
382
|
+
for (const [key, tasks] of Object.entries(standardTasks)) {
|
|
383
|
+
allTasks[key] = tasks // standard overwrites inline for same key
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Standalone and unassigned tasks
|
|
387
|
+
const looseTasks = extractLooseTasks(d, ['standalone_tasks', 'unassigned_tasks'])
|
|
388
|
+
if (looseTasks.length > 0) {
|
|
389
|
+
const existing = allTasks['_unassigned'] || []
|
|
390
|
+
allTasks['_unassigned'] = [...existing, ...looseTasks]
|
|
391
|
+
// Add a synthetic milestone for unassigned tasks if not already present
|
|
392
|
+
if (!seenIds.has('_unassigned')) {
|
|
393
|
+
seenIds.add('_unassigned')
|
|
394
|
+
allMilestones.push({
|
|
395
|
+
id: '_unassigned',
|
|
396
|
+
name: 'Unassigned Tasks',
|
|
397
|
+
status: 'in_progress',
|
|
398
|
+
progress: 0,
|
|
399
|
+
started: null,
|
|
400
|
+
completed: null,
|
|
401
|
+
estimated_weeks: '0',
|
|
402
|
+
tasks_completed: looseTasks.filter((t) => t.status === 'completed').length,
|
|
403
|
+
tasks_total: looseTasks.length,
|
|
404
|
+
notes: 'Tasks not assigned to a specific milestone',
|
|
405
|
+
extra: { synthetic: true },
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
}
|
|
280
409
|
|
|
281
410
|
return {
|
|
282
411
|
project: normalizeProject(d.project),
|
|
283
|
-
milestones,
|
|
284
|
-
tasks:
|
|
412
|
+
milestones: allMilestones,
|
|
413
|
+
tasks: allTasks,
|
|
285
414
|
recent_work: normalizeWorkEntries(d.recent_work),
|
|
286
|
-
next_steps:
|
|
415
|
+
next_steps: collectNextSteps(d),
|
|
287
416
|
notes: normalizeStringArray(d.notes),
|
|
288
417
|
current_blockers: normalizeStringArray(d.current_blockers),
|
|
289
418
|
documentation: normalizeDocStats(d.documentation),
|