@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.
@@ -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
- export function Sidebar() {
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: 'table' | 'tree'
3
- onChange: (view: 'table' | 'tree') => void
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
- <button
10
- onClick={() => onChange('table')}
11
- className={`px-3 py-1 text-xs rounded-md transition-colors ${
12
- value === 'table'
13
- ? 'bg-gray-700 text-gray-100'
14
- : 'text-gray-500 hover:text-gray-300'
15
- }`}
16
- >
17
- Table
18
- </button>
19
- <button
20
- onClick={() => onChange('tree')}
21
- className={`px-3 py-1 text-xs rounded-md transition-colors ${
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
  })
@@ -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
- const n = Number(value)
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
- const milestones = normalizeMilestones(d.milestones)
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: normalizeTasks(d.tasks, milestones),
412
+ milestones: allMilestones,
413
+ tasks: allTasks,
285
414
  recent_work: normalizeWorkEntries(d.recent_work),
286
- next_steps: normalizeStringArray(d.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),