@prmichaelsen/acp-visualizer 0.1.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/README.md +68 -0
- package/agent/commands/acp.clarification-address.md +417 -0
- package/agent/commands/acp.clarification-capture.md +386 -0
- package/agent/commands/acp.clarification-create.md +437 -0
- package/agent/commands/acp.clarifications-research.md +326 -0
- package/agent/commands/acp.command-create.md +432 -0
- package/agent/commands/acp.design-create.md +286 -0
- package/agent/commands/acp.design-reference.md +355 -0
- package/agent/commands/acp.handoff.md +270 -0
- package/agent/commands/acp.index.md +423 -0
- package/agent/commands/acp.init.md +546 -0
- package/agent/commands/acp.package-create.md +895 -0
- package/agent/commands/acp.package-info.md +212 -0
- package/agent/commands/acp.package-install.md +539 -0
- package/agent/commands/acp.package-list.md +280 -0
- package/agent/commands/acp.package-publish.md +541 -0
- package/agent/commands/acp.package-remove.md +293 -0
- package/agent/commands/acp.package-search.md +307 -0
- package/agent/commands/acp.package-update.md +361 -0
- package/agent/commands/acp.package-validate.md +540 -0
- package/agent/commands/acp.pattern-create.md +386 -0
- package/agent/commands/acp.plan.md +587 -0
- package/agent/commands/acp.proceed.md +882 -0
- package/agent/commands/acp.project-create.md +675 -0
- package/agent/commands/acp.project-info.md +312 -0
- package/agent/commands/acp.project-list.md +226 -0
- package/agent/commands/acp.project-remove.md +379 -0
- package/agent/commands/acp.project-set.md +227 -0
- package/agent/commands/acp.project-update.md +307 -0
- package/agent/commands/acp.projects-restore.md +228 -0
- package/agent/commands/acp.projects-sync.md +347 -0
- package/agent/commands/acp.report.md +407 -0
- package/agent/commands/acp.resume.md +239 -0
- package/agent/commands/acp.sessions.md +301 -0
- package/agent/commands/acp.status.md +293 -0
- package/agent/commands/acp.sync.md +364 -0
- package/agent/commands/acp.task-create.md +500 -0
- package/agent/commands/acp.update.md +302 -0
- package/agent/commands/acp.validate.md +466 -0
- package/agent/commands/acp.version-check-for-updates.md +276 -0
- package/agent/commands/acp.version-check.md +191 -0
- package/agent/commands/acp.version-update.md +289 -0
- package/agent/commands/command.template.md +339 -0
- package/agent/commands/git.commit.md +526 -0
- package/agent/commands/git.init.md +514 -0
- package/agent/commands/tanstack-cloudflare.deploy.md +272 -0
- package/agent/commands/tanstack-cloudflare.tail.md +275 -0
- package/agent/design/.gitkeep +0 -0
- package/agent/design/design.template.md +154 -0
- package/agent/design/local.dashboard-layout-routing.md +288 -0
- package/agent/design/local.data-model-yaml-parsing.md +310 -0
- package/agent/design/local.search-filtering.md +331 -0
- package/agent/design/local.server-api-auto-refresh.md +235 -0
- package/agent/design/local.table-tree-views.md +299 -0
- package/agent/design/local.visualizer-requirements.md +349 -0
- package/agent/design/requirements.template.md +387 -0
- package/agent/index/.gitkeep +0 -0
- package/agent/index/acp.core.yaml +137 -0
- package/agent/index/local.main.template.yaml +37 -0
- package/agent/manifest.template.yaml +13 -0
- package/agent/manifest.yaml +302 -0
- package/agent/milestones/.gitkeep +0 -0
- package/agent/milestones/milestone-1-project-scaffold-data-pipeline.md +67 -0
- package/agent/milestones/milestone-1-{title}.template.md +206 -0
- package/agent/milestones/milestone-2-dashboard-views-interaction.md +79 -0
- package/agent/package.template.yaml +86 -0
- package/agent/patterns/.gitkeep +0 -0
- package/agent/patterns/bootstrap.template.md +1237 -0
- package/agent/patterns/pattern.template.md +382 -0
- package/agent/patterns/tanstack-cloudflare.acl-permissions.md +332 -0
- package/agent/patterns/tanstack-cloudflare.action-bar-item.md +416 -0
- package/agent/patterns/tanstack-cloudflare.api-route-handlers.md +401 -0
- package/agent/patterns/tanstack-cloudflare.auth-session-management.md +387 -0
- package/agent/patterns/tanstack-cloudflare.card-and-list.md +271 -0
- package/agent/patterns/tanstack-cloudflare.chat-engine.md +353 -0
- package/agent/patterns/tanstack-cloudflare.confirmation-tokens.md +346 -0
- package/agent/patterns/tanstack-cloudflare.durable-objects-websocket.md +516 -0
- package/agent/patterns/tanstack-cloudflare.email-service.md +431 -0
- package/agent/patterns/tanstack-cloudflare.expander.md +98 -0
- package/agent/patterns/tanstack-cloudflare.fcm-push.md +115 -0
- package/agent/patterns/tanstack-cloudflare.firebase-anonymous-sessions.md +441 -0
- package/agent/patterns/tanstack-cloudflare.firebase-auth.md +348 -0
- package/agent/patterns/tanstack-cloudflare.firebase-firestore.md +550 -0
- package/agent/patterns/tanstack-cloudflare.firebase-storage.md +369 -0
- package/agent/patterns/tanstack-cloudflare.form-controls.md +145 -0
- package/agent/patterns/tanstack-cloudflare.global-search-context.md +93 -0
- package/agent/patterns/tanstack-cloudflare.image-carousel.md +126 -0
- package/agent/patterns/tanstack-cloudflare.library-services.md +553 -0
- package/agent/patterns/tanstack-cloudflare.lightbox.md +169 -0
- package/agent/patterns/tanstack-cloudflare.markdown-content.md +115 -0
- package/agent/patterns/tanstack-cloudflare.mention-suggestions.md +98 -0
- package/agent/patterns/tanstack-cloudflare.modal.md +156 -0
- package/agent/patterns/tanstack-cloudflare.nextjs-to-tanstack-routing.md +461 -0
- package/agent/patterns/tanstack-cloudflare.notifications-engine.md +151 -0
- package/agent/patterns/tanstack-cloudflare.oauth-token-refresh.md +90 -0
- package/agent/patterns/tanstack-cloudflare.og-metadata.md +296 -0
- package/agent/patterns/tanstack-cloudflare.pagination.md +442 -0
- package/agent/patterns/tanstack-cloudflare.pill-input.md +220 -0
- package/agent/patterns/tanstack-cloudflare.provider-adapter.md +401 -0
- package/agent/patterns/tanstack-cloudflare.rate-limiting.md +323 -0
- package/agent/patterns/tanstack-cloudflare.scheduled-tasks.md +338 -0
- package/agent/patterns/tanstack-cloudflare.searchable-settings.md +375 -0
- package/agent/patterns/tanstack-cloudflare.slide-over.md +129 -0
- package/agent/patterns/tanstack-cloudflare.ssr-preload.md +571 -0
- package/agent/patterns/tanstack-cloudflare.third-party-api-integration.md +508 -0
- package/agent/patterns/tanstack-cloudflare.toast-system.md +142 -0
- package/agent/patterns/tanstack-cloudflare.unified-header.md +280 -0
- package/agent/patterns/tanstack-cloudflare.user-scoped-collections.md +628 -0
- package/agent/patterns/tanstack-cloudflare.websocket-manager.md +237 -0
- package/agent/patterns/tanstack-cloudflare.wrangler-configuration.md +358 -0
- package/agent/patterns/tanstack-cloudflare.zod-schema-validation.md +336 -0
- package/agent/progress.template.yaml +161 -0
- package/agent/progress.yaml +145 -0
- package/agent/schemas/package.schema.yaml +276 -0
- package/agent/scripts/acp.common.sh +1781 -0
- package/agent/scripts/acp.install.sh +333 -0
- package/agent/scripts/acp.package-create.sh +924 -0
- package/agent/scripts/acp.package-info.sh +288 -0
- package/agent/scripts/acp.package-install.sh +893 -0
- package/agent/scripts/acp.package-list.sh +311 -0
- package/agent/scripts/acp.package-publish.sh +420 -0
- package/agent/scripts/acp.package-remove.sh +348 -0
- package/agent/scripts/acp.package-search.sh +156 -0
- package/agent/scripts/acp.package-update.sh +517 -0
- package/agent/scripts/acp.package-validate.sh +1018 -0
- package/agent/scripts/acp.uninstall.sh +85 -0
- package/agent/scripts/acp.version-check-for-updates.sh +98 -0
- package/agent/scripts/acp.version-check.sh +47 -0
- package/agent/scripts/acp.version-update.sh +176 -0
- package/agent/scripts/acp.yaml-parser.sh +985 -0
- package/agent/scripts/acp.yaml-validate.sh +205 -0
- package/agent/tasks/.gitkeep +0 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-1-initialize-tanstack-start-project.md +210 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-2-implement-data-model-yaml-parser.md +294 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-3-build-server-api-data-loading.md +193 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-4-add-auto-refresh-sse.md +262 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-10-polish-integration-testing.md +156 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-5-build-dashboard-layout-routing.md +178 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-6-build-overview-page.md +141 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-7-implement-milestone-table-view.md +153 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-8-implement-milestone-tree-view.md +174 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-9-implement-search-filtering.md +233 -0
- package/agent/tasks/task-1-{title}.template.md +244 -0
- package/bin/visualize.mjs +84 -0
- package/package.json +48 -0
- package/src/components/ExtraFieldsBadge.tsx +15 -0
- package/src/components/FilterBar.tsx +33 -0
- package/src/components/Header.tsx +23 -0
- package/src/components/MilestoneTable.tsx +167 -0
- package/src/components/MilestoneTree.tsx +84 -0
- package/src/components/ProgressBar.tsx +20 -0
- package/src/components/SearchInput.tsx +22 -0
- package/src/components/Sidebar.tsx +54 -0
- package/src/components/StatusBadge.tsx +23 -0
- package/src/components/StatusDot.tsx +12 -0
- package/src/components/TaskList.tsx +36 -0
- package/src/components/ViewToggle.tsx +31 -0
- package/src/lib/config.ts +8 -0
- package/src/lib/file-watcher.ts +43 -0
- package/src/lib/search.ts +48 -0
- package/src/lib/types.ts +73 -0
- package/src/lib/useAutoRefresh.ts +31 -0
- package/src/lib/useCollapse.ts +31 -0
- package/src/lib/useFilteredData.ts +55 -0
- package/src/lib/yaml-loader-real.spec.ts +47 -0
- package/src/lib/yaml-loader.spec.ts +201 -0
- package/src/lib/yaml-loader.ts +265 -0
- package/src/routeTree.gen.ts +140 -0
- package/src/router.tsx +10 -0
- package/src/routes/__root.tsx +75 -0
- package/src/routes/api/watch.ts +29 -0
- package/src/routes/index.tsx +115 -0
- package/src/routes/milestones.tsx +50 -0
- package/src/routes/search.tsx +84 -0
- package/src/routes/tasks.tsx +63 -0
- package/src/services/progress-database.service.ts +46 -0
- package/src/styles.css +25 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +16 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useRef, useState, useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
export function useCollapse(open: boolean) {
|
|
4
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
5
|
+
const [height, setHeight] = useState<number | undefined>(open ? undefined : 0)
|
|
6
|
+
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const el = ref.current
|
|
9
|
+
if (!el) return
|
|
10
|
+
|
|
11
|
+
if (open) {
|
|
12
|
+
setHeight(el.scrollHeight)
|
|
13
|
+
const id = setTimeout(() => setHeight(undefined), 300)
|
|
14
|
+
return () => clearTimeout(id)
|
|
15
|
+
} else {
|
|
16
|
+
setHeight(el.scrollHeight)
|
|
17
|
+
requestAnimationFrame(() => {
|
|
18
|
+
requestAnimationFrame(() => setHeight(0))
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
}, [open])
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
ref,
|
|
25
|
+
style: {
|
|
26
|
+
height: height != null ? `${height}px` : ('auto' as const),
|
|
27
|
+
overflow: 'hidden' as const,
|
|
28
|
+
transition: 'height 300ms cubic-bezier(0.4, 0, 0.2, 1)',
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { buildSearchIndex } from './search'
|
|
3
|
+
import type { ProgressData, Status } from './types'
|
|
4
|
+
|
|
5
|
+
interface FilterState {
|
|
6
|
+
status: Status | 'all'
|
|
7
|
+
search: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useFilteredData(
|
|
11
|
+
data: ProgressData | null,
|
|
12
|
+
filters: FilterState,
|
|
13
|
+
): ProgressData | null {
|
|
14
|
+
return useMemo(() => {
|
|
15
|
+
if (!data) return null
|
|
16
|
+
|
|
17
|
+
let milestones = data.milestones
|
|
18
|
+
let tasks = { ...data.tasks }
|
|
19
|
+
|
|
20
|
+
// Status filter
|
|
21
|
+
if (filters.status !== 'all') {
|
|
22
|
+
milestones = milestones.filter((m) => m.status === filters.status)
|
|
23
|
+
tasks = Object.fromEntries(
|
|
24
|
+
Object.entries(tasks).map(([id, ts]) => [
|
|
25
|
+
id,
|
|
26
|
+
ts.filter((t) => t.status === filters.status),
|
|
27
|
+
]),
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Search filter
|
|
32
|
+
if (filters.search.trim()) {
|
|
33
|
+
const index = buildSearchIndex(data)
|
|
34
|
+
const results = index.search(filters.search)
|
|
35
|
+
const matchedMilestoneIds = new Set(
|
|
36
|
+
results.map((r) => r.item.milestone.id),
|
|
37
|
+
)
|
|
38
|
+
const matchedTaskIds = new Set(
|
|
39
|
+
results.filter((r) => r.item.task).map((r) => r.item.task!.id),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
milestones = milestones.filter((m) => matchedMilestoneIds.has(m.id))
|
|
43
|
+
tasks = Object.fromEntries(
|
|
44
|
+
Object.entries(tasks).map(([id, ts]) => [
|
|
45
|
+
id,
|
|
46
|
+
ts.filter(
|
|
47
|
+
(t) => matchedTaskIds.has(t.id) || matchedMilestoneIds.has(id),
|
|
48
|
+
),
|
|
49
|
+
]),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { ...data, milestones, tasks }
|
|
54
|
+
}, [data, filters.status, filters.search])
|
|
55
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'fs'
|
|
3
|
+
import { resolve } from 'path'
|
|
4
|
+
import { parseProgressYaml } from './yaml-loader'
|
|
5
|
+
|
|
6
|
+
const PROJECT_ROOT = resolve(__dirname, '../../')
|
|
7
|
+
|
|
8
|
+
describe('parseProgressYaml with real files', () => {
|
|
9
|
+
it('parses the local project progress.yaml', () => {
|
|
10
|
+
const raw = readFileSync(resolve(PROJECT_ROOT, 'agent/progress.yaml'), 'utf-8')
|
|
11
|
+
const result = parseProgressYaml(raw)
|
|
12
|
+
|
|
13
|
+
expect(result.project.name).toBe('agent-context-protocol-visualizer')
|
|
14
|
+
expect(result.milestones.length).toBeGreaterThan(0)
|
|
15
|
+
expect(result.milestones[0].id).toBeTruthy()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('parses a large real-world progress.yaml (remember-core)', () => {
|
|
19
|
+
const path = '/home/prmichaelsen/.acp/projects/remember-core/agent/progress.yaml'
|
|
20
|
+
let raw: string
|
|
21
|
+
try {
|
|
22
|
+
raw = readFileSync(path, 'utf-8')
|
|
23
|
+
} catch {
|
|
24
|
+
console.log('Skipping — remember-core progress.yaml not found')
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const result = parseProgressYaml(raw)
|
|
29
|
+
|
|
30
|
+
expect(result.project.name).toBe('remember-core')
|
|
31
|
+
expect(result.milestones.length).toBeGreaterThan(0)
|
|
32
|
+
|
|
33
|
+
const totalTasks = Object.values(result.tasks).reduce(
|
|
34
|
+
(sum, ts) => sum + ts.length,
|
|
35
|
+
0,
|
|
36
|
+
)
|
|
37
|
+
expect(totalTasks).toBeGreaterThan(0)
|
|
38
|
+
|
|
39
|
+
for (const m of result.milestones) {
|
|
40
|
+
expect(['completed', 'in_progress', 'not_started']).toContain(m.status)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log(
|
|
44
|
+
`Parsed: ${result.milestones.length} milestones, ${totalTasks} tasks, ${result.recent_work.length} work entries`,
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { parseProgressYaml } from './yaml-loader'
|
|
3
|
+
|
|
4
|
+
describe('parseProgressYaml', () => {
|
|
5
|
+
it('parses a complete progress.yaml', () => {
|
|
6
|
+
const yaml = `
|
|
7
|
+
project:
|
|
8
|
+
name: test-project
|
|
9
|
+
version: 1.0.0
|
|
10
|
+
started: "2026-01-01"
|
|
11
|
+
status: in_progress
|
|
12
|
+
current_milestone: milestone_1
|
|
13
|
+
description: A test project
|
|
14
|
+
|
|
15
|
+
milestones:
|
|
16
|
+
- id: milestone_1
|
|
17
|
+
name: First Milestone
|
|
18
|
+
status: in_progress
|
|
19
|
+
progress: 50
|
|
20
|
+
started: "2026-01-01"
|
|
21
|
+
tasks_completed: 1
|
|
22
|
+
tasks_total: 2
|
|
23
|
+
|
|
24
|
+
tasks:
|
|
25
|
+
milestone_1:
|
|
26
|
+
- id: task_1
|
|
27
|
+
name: First Task
|
|
28
|
+
status: completed
|
|
29
|
+
file: agent/tasks/task-1.md
|
|
30
|
+
estimated_hours: "2"
|
|
31
|
+
completed_date: "2026-01-02"
|
|
32
|
+
- id: task_2
|
|
33
|
+
name: Second Task
|
|
34
|
+
status: not_started
|
|
35
|
+
|
|
36
|
+
recent_work:
|
|
37
|
+
- date: "2026-01-02"
|
|
38
|
+
description: Did stuff
|
|
39
|
+
items:
|
|
40
|
+
- item 1
|
|
41
|
+
- item 2
|
|
42
|
+
|
|
43
|
+
next_steps:
|
|
44
|
+
- Do next thing
|
|
45
|
+
|
|
46
|
+
notes:
|
|
47
|
+
- A note
|
|
48
|
+
|
|
49
|
+
current_blockers: []
|
|
50
|
+
|
|
51
|
+
progress:
|
|
52
|
+
planning: 100
|
|
53
|
+
implementation: 50
|
|
54
|
+
overall: 60
|
|
55
|
+
`
|
|
56
|
+
const result = parseProgressYaml(yaml)
|
|
57
|
+
|
|
58
|
+
expect(result.project.name).toBe('test-project')
|
|
59
|
+
expect(result.project.version).toBe('1.0.0')
|
|
60
|
+
expect(result.project.status).toBe('in_progress')
|
|
61
|
+
expect(result.milestones).toHaveLength(1)
|
|
62
|
+
expect(result.milestones[0].name).toBe('First Milestone')
|
|
63
|
+
expect(result.milestones[0].progress).toBe(50)
|
|
64
|
+
expect(result.tasks.milestone_1).toHaveLength(2)
|
|
65
|
+
expect(result.tasks.milestone_1[0].status).toBe('completed')
|
|
66
|
+
expect(result.tasks.milestone_1[1].status).toBe('not_started')
|
|
67
|
+
expect(result.recent_work).toHaveLength(1)
|
|
68
|
+
expect(result.recent_work[0].items).toEqual(['item 1', 'item 2'])
|
|
69
|
+
expect(result.next_steps).toEqual(['Do next thing'])
|
|
70
|
+
expect(result.progress.overall).toBe(60)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('handles empty/minimal YAML', () => {
|
|
74
|
+
const result = parseProgressYaml('')
|
|
75
|
+
expect(result.project.name).toBe('Unknown')
|
|
76
|
+
expect(result.milestones).toEqual([])
|
|
77
|
+
expect(result.tasks).toEqual({})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('handles totally invalid YAML', () => {
|
|
81
|
+
const result = parseProgressYaml('{{{{not yaml at all')
|
|
82
|
+
expect(result.project.name).toBe('Unknown')
|
|
83
|
+
expect(result.milestones).toEqual([])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('handles missing sections gracefully', () => {
|
|
87
|
+
const yaml = `
|
|
88
|
+
project:
|
|
89
|
+
name: minimal
|
|
90
|
+
`
|
|
91
|
+
const result = parseProgressYaml(yaml)
|
|
92
|
+
expect(result.project.name).toBe('minimal')
|
|
93
|
+
expect(result.project.status).toBe('not_started')
|
|
94
|
+
expect(result.milestones).toEqual([])
|
|
95
|
+
expect(result.tasks).toEqual({})
|
|
96
|
+
expect(result.next_steps).toEqual([])
|
|
97
|
+
expect(result.notes).toEqual([])
|
|
98
|
+
expect(result.current_blockers).toEqual([])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('fuzzy-matches status values', () => {
|
|
102
|
+
const yaml = `
|
|
103
|
+
milestones:
|
|
104
|
+
- id: m1
|
|
105
|
+
name: Done One
|
|
106
|
+
status: done
|
|
107
|
+
- id: m2
|
|
108
|
+
name: Active One
|
|
109
|
+
status: active
|
|
110
|
+
- id: m3
|
|
111
|
+
name: WIP One
|
|
112
|
+
status: wip
|
|
113
|
+
- id: m4
|
|
114
|
+
name: In Progress One
|
|
115
|
+
status: "in progress"
|
|
116
|
+
- id: m5
|
|
117
|
+
name: Complete One
|
|
118
|
+
status: complete
|
|
119
|
+
- id: m6
|
|
120
|
+
name: Started One
|
|
121
|
+
status: started
|
|
122
|
+
`
|
|
123
|
+
const result = parseProgressYaml(yaml)
|
|
124
|
+
expect(result.milestones[0].status).toBe('completed')
|
|
125
|
+
expect(result.milestones[1].status).toBe('in_progress')
|
|
126
|
+
expect(result.milestones[2].status).toBe('in_progress')
|
|
127
|
+
expect(result.milestones[3].status).toBe('in_progress')
|
|
128
|
+
expect(result.milestones[4].status).toBe('completed')
|
|
129
|
+
expect(result.milestones[5].status).toBe('in_progress')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('preserves unknown properties in extra fields', () => {
|
|
133
|
+
const yaml = `
|
|
134
|
+
project:
|
|
135
|
+
name: test
|
|
136
|
+
custom_field: hello
|
|
137
|
+
priority: high
|
|
138
|
+
|
|
139
|
+
milestones:
|
|
140
|
+
- id: m1
|
|
141
|
+
name: Test
|
|
142
|
+
status: in_progress
|
|
143
|
+
risk_level: high
|
|
144
|
+
owner: alice
|
|
145
|
+
`
|
|
146
|
+
const result = parseProgressYaml(yaml)
|
|
147
|
+
expect(result.project.extra).toEqual({ custom_field: 'hello', priority: 'high' })
|
|
148
|
+
expect(result.milestones[0].extra).toEqual({ risk_level: 'high', owner: 'alice' })
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('resolves key aliases', () => {
|
|
152
|
+
const yaml = `
|
|
153
|
+
tasks:
|
|
154
|
+
m1:
|
|
155
|
+
- id: t1
|
|
156
|
+
title: Aliased Name
|
|
157
|
+
est_hours: "4"
|
|
158
|
+
done_date: "2026-01-05"
|
|
159
|
+
path: agent/tasks/t1.md
|
|
160
|
+
`
|
|
161
|
+
const result = parseProgressYaml(yaml)
|
|
162
|
+
const task = result.tasks.m1[0]
|
|
163
|
+
expect(task.name).toBe('Aliased Name')
|
|
164
|
+
expect(task.estimated_hours).toBe('4')
|
|
165
|
+
expect(task.completed_date).toBe('2026-01-05')
|
|
166
|
+
expect(task.file).toBe('agent/tasks/t1.md')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('coerces string-for-array', () => {
|
|
170
|
+
const yaml = `
|
|
171
|
+
next_steps: "just one step"
|
|
172
|
+
notes: "single note"
|
|
173
|
+
`
|
|
174
|
+
const result = parseProgressYaml(yaml)
|
|
175
|
+
expect(result.next_steps).toEqual(['just one step'])
|
|
176
|
+
expect(result.notes).toEqual(['single note'])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('generates IDs when missing', () => {
|
|
180
|
+
const yaml = `
|
|
181
|
+
milestones:
|
|
182
|
+
- name: No ID Milestone
|
|
183
|
+
status: not_started
|
|
184
|
+
`
|
|
185
|
+
const result = parseProgressYaml(yaml)
|
|
186
|
+
expect(result.milestones[0].id).toBe('milestone_1')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('handles null values in dates', () => {
|
|
190
|
+
const yaml = `
|
|
191
|
+
milestones:
|
|
192
|
+
- id: m1
|
|
193
|
+
name: Test
|
|
194
|
+
started: null
|
|
195
|
+
completed: null
|
|
196
|
+
`
|
|
197
|
+
const result = parseProgressYaml(yaml)
|
|
198
|
+
expect(result.milestones[0].started).toBeNull()
|
|
199
|
+
expect(result.milestones[0].completed).toBeNull()
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import yaml from 'js-yaml'
|
|
2
|
+
import type {
|
|
3
|
+
ProgressData,
|
|
4
|
+
ProjectMetadata,
|
|
5
|
+
Milestone,
|
|
6
|
+
Task,
|
|
7
|
+
WorkEntry,
|
|
8
|
+
DocumentationStats,
|
|
9
|
+
ProgressSummary,
|
|
10
|
+
Status,
|
|
11
|
+
ExtraFields,
|
|
12
|
+
} from './types'
|
|
13
|
+
|
|
14
|
+
// --- Key alias maps for agent drift ---
|
|
15
|
+
|
|
16
|
+
const PROJECT_ALIASES: Record<string, string> = {
|
|
17
|
+
project_name: 'name',
|
|
18
|
+
title: 'name',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MILESTONE_ALIASES: Record<string, string> = {
|
|
22
|
+
title: 'name',
|
|
23
|
+
est_weeks: 'estimated_weeks',
|
|
24
|
+
weeks: 'estimated_weeks',
|
|
25
|
+
tasks_done: 'tasks_completed',
|
|
26
|
+
total_tasks: 'tasks_total',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TASK_ALIASES: Record<string, string> = {
|
|
30
|
+
title: 'name',
|
|
31
|
+
est_hours: 'estimated_hours',
|
|
32
|
+
hours: 'estimated_hours',
|
|
33
|
+
estimate: 'estimated_hours',
|
|
34
|
+
completed: 'completed_date',
|
|
35
|
+
done_date: 'completed_date',
|
|
36
|
+
filename: 'file',
|
|
37
|
+
path: 'file',
|
|
38
|
+
milestone: 'milestone_id',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- Core helpers ---
|
|
42
|
+
|
|
43
|
+
function normalizeStatus(value: unknown): Status {
|
|
44
|
+
const s = String(value || 'not_started')
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.replace(/[\s-]/g, '_')
|
|
47
|
+
if (s === 'completed' || s === 'done' || s === 'complete') return 'completed'
|
|
48
|
+
if (s === 'in_progress' || s === 'active' || s === 'wip' || s === 'started') return 'in_progress'
|
|
49
|
+
return 'not_started'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function safeString(value: unknown, fallback = ''): string {
|
|
53
|
+
if (value == null) return fallback
|
|
54
|
+
return String(value)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function safeNumber(value: unknown, fallback = 0): number {
|
|
58
|
+
const n = Number(value)
|
|
59
|
+
return Number.isFinite(n) ? n : fallback
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeStringArray(value: unknown): string[] {
|
|
63
|
+
if (!value) return []
|
|
64
|
+
if (typeof value === 'string') return [value]
|
|
65
|
+
if (!Array.isArray(value)) return []
|
|
66
|
+
return value.map(String)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveAliases(
|
|
70
|
+
obj: Record<string, unknown>,
|
|
71
|
+
aliases: Record<string, string>,
|
|
72
|
+
): Record<string, unknown> {
|
|
73
|
+
const resolved: Record<string, unknown> = {}
|
|
74
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
75
|
+
const canonical = aliases[key] || key
|
|
76
|
+
// Don't overwrite if canonical already set
|
|
77
|
+
if (!(canonical in resolved)) {
|
|
78
|
+
resolved[canonical] = value
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return resolved
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractKnown(
|
|
85
|
+
obj: Record<string, unknown>,
|
|
86
|
+
knownKeys: string[],
|
|
87
|
+
): { known: Record<string, unknown>; extra: ExtraFields } {
|
|
88
|
+
const known: Record<string, unknown> = {}
|
|
89
|
+
const extra: ExtraFields = {}
|
|
90
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
91
|
+
if (knownKeys.includes(key)) {
|
|
92
|
+
known[key] = value
|
|
93
|
+
} else {
|
|
94
|
+
extra[key] = value
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { known, extra }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
101
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
102
|
+
return value as Record<string, unknown>
|
|
103
|
+
}
|
|
104
|
+
return {}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- Entity normalizers ---
|
|
108
|
+
|
|
109
|
+
const PROJECT_KEYS = ['name', 'version', 'started', 'status', 'current_milestone', 'description']
|
|
110
|
+
|
|
111
|
+
function normalizeProject(raw: unknown): ProjectMetadata {
|
|
112
|
+
const obj = resolveAliases(asRecord(raw), PROJECT_ALIASES)
|
|
113
|
+
const { known, extra } = extractKnown(obj, PROJECT_KEYS)
|
|
114
|
+
return {
|
|
115
|
+
name: safeString(known.name, 'Untitled Project'),
|
|
116
|
+
version: safeString(known.version, '0.0.0'),
|
|
117
|
+
started: safeString(known.started),
|
|
118
|
+
status: normalizeStatus(known.status),
|
|
119
|
+
current_milestone: known.current_milestone ? safeString(known.current_milestone) : undefined,
|
|
120
|
+
description: safeString(known.description),
|
|
121
|
+
extra,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const MILESTONE_KEYS = [
|
|
126
|
+
'id', 'name', 'status', 'progress', 'started', 'completed',
|
|
127
|
+
'estimated_weeks', 'tasks_completed', 'tasks_total', 'notes',
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
function normalizeMilestone(raw: unknown, index: number): Milestone {
|
|
131
|
+
const obj = resolveAliases(asRecord(raw), MILESTONE_ALIASES)
|
|
132
|
+
const { known, extra } = extractKnown(obj, MILESTONE_KEYS)
|
|
133
|
+
return {
|
|
134
|
+
id: safeString(known.id, `milestone_${index + 1}`),
|
|
135
|
+
name: safeString(known.name, `Milestone ${index + 1}`),
|
|
136
|
+
status: normalizeStatus(known.status),
|
|
137
|
+
progress: safeNumber(known.progress),
|
|
138
|
+
started: known.started ? safeString(known.started) : null,
|
|
139
|
+
completed: known.completed ? safeString(known.completed) : null,
|
|
140
|
+
estimated_weeks: safeString(known.estimated_weeks, '0'),
|
|
141
|
+
tasks_completed: safeNumber(known.tasks_completed),
|
|
142
|
+
tasks_total: safeNumber(known.tasks_total),
|
|
143
|
+
notes: safeString(known.notes),
|
|
144
|
+
extra,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function normalizeMilestones(raw: unknown): Milestone[] {
|
|
149
|
+
if (!Array.isArray(raw)) return []
|
|
150
|
+
return raw.map((item, i) => normalizeMilestone(item, i))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const TASK_KEYS = [
|
|
154
|
+
'id', 'name', 'status', 'milestone_id', 'file',
|
|
155
|
+
'estimated_hours', 'completed_date', 'notes',
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
function normalizeTask(raw: unknown, milestoneId: string, index: number): Task {
|
|
159
|
+
const obj = resolveAliases(asRecord(raw), TASK_ALIASES)
|
|
160
|
+
const { known, extra } = extractKnown(obj, TASK_KEYS)
|
|
161
|
+
return {
|
|
162
|
+
id: safeString(known.id, `task_${index + 1}`),
|
|
163
|
+
name: safeString(known.name, `Task ${index + 1}`),
|
|
164
|
+
status: normalizeStatus(known.status),
|
|
165
|
+
milestone_id: safeString(known.milestone_id, milestoneId),
|
|
166
|
+
file: safeString(known.file),
|
|
167
|
+
estimated_hours: safeString(known.estimated_hours, '0'),
|
|
168
|
+
completed_date: known.completed_date ? safeString(known.completed_date) : null,
|
|
169
|
+
notes: safeString(known.notes),
|
|
170
|
+
extra,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeTasks(raw: unknown): Record<string, Task[]> {
|
|
175
|
+
const result: Record<string, Task[]> = {}
|
|
176
|
+
const obj = asRecord(raw)
|
|
177
|
+
for (const [milestoneId, tasks] of Object.entries(obj)) {
|
|
178
|
+
if (Array.isArray(tasks)) {
|
|
179
|
+
result[milestoneId] = tasks.map((t, i) => normalizeTask(t, milestoneId, i))
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return result
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizeWorkEntry(raw: unknown): WorkEntry {
|
|
186
|
+
const obj = asRecord(raw)
|
|
187
|
+
const knownKeys = ['date', 'description', 'items']
|
|
188
|
+
const { known, extra } = extractKnown(obj, knownKeys)
|
|
189
|
+
return {
|
|
190
|
+
date: safeString(known.date),
|
|
191
|
+
description: safeString(known.description),
|
|
192
|
+
items: normalizeStringArray(known.items),
|
|
193
|
+
extra,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeWorkEntries(raw: unknown): WorkEntry[] {
|
|
198
|
+
if (!Array.isArray(raw)) return []
|
|
199
|
+
return raw.map(normalizeWorkEntry)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizeDocStats(raw: unknown): DocumentationStats {
|
|
203
|
+
const obj = asRecord(raw)
|
|
204
|
+
return {
|
|
205
|
+
design_documents: safeNumber(obj.design_documents),
|
|
206
|
+
milestone_documents: safeNumber(obj.milestone_documents),
|
|
207
|
+
pattern_documents: safeNumber(obj.pattern_documents),
|
|
208
|
+
task_documents: safeNumber(obj.task_documents),
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function normalizeProgress(raw: unknown): ProgressSummary {
|
|
213
|
+
const obj = asRecord(raw)
|
|
214
|
+
return {
|
|
215
|
+
planning: safeNumber(obj.planning),
|
|
216
|
+
implementation: safeNumber(obj.implementation),
|
|
217
|
+
overall: safeNumber(obj.overall),
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- Main parser ---
|
|
222
|
+
|
|
223
|
+
const EMPTY_PROGRESS_DATA: ProgressData = {
|
|
224
|
+
project: {
|
|
225
|
+
name: 'Unknown',
|
|
226
|
+
version: '0.0.0',
|
|
227
|
+
started: '',
|
|
228
|
+
status: 'not_started',
|
|
229
|
+
description: '',
|
|
230
|
+
extra: {},
|
|
231
|
+
},
|
|
232
|
+
milestones: [],
|
|
233
|
+
tasks: {},
|
|
234
|
+
recent_work: [],
|
|
235
|
+
next_steps: [],
|
|
236
|
+
notes: [],
|
|
237
|
+
current_blockers: [],
|
|
238
|
+
documentation: { design_documents: 0, milestone_documents: 0, pattern_documents: 0, task_documents: 0 },
|
|
239
|
+
progress: { planning: 0, implementation: 0, overall: 0 },
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function parseProgressYaml(raw: string): ProgressData {
|
|
243
|
+
try {
|
|
244
|
+
// json: true allows duplicated keys (last wins) — common in agent-maintained YAML
|
|
245
|
+
const doc = yaml.load(raw, { json: true })
|
|
246
|
+
if (!doc || typeof doc !== 'object') {
|
|
247
|
+
return { ...EMPTY_PROGRESS_DATA }
|
|
248
|
+
}
|
|
249
|
+
const d = doc as Record<string, unknown>
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
project: normalizeProject(d.project),
|
|
253
|
+
milestones: normalizeMilestones(d.milestones),
|
|
254
|
+
tasks: normalizeTasks(d.tasks),
|
|
255
|
+
recent_work: normalizeWorkEntries(d.recent_work),
|
|
256
|
+
next_steps: normalizeStringArray(d.next_steps),
|
|
257
|
+
notes: normalizeStringArray(d.notes),
|
|
258
|
+
current_blockers: normalizeStringArray(d.current_blockers),
|
|
259
|
+
documentation: normalizeDocStats(d.documentation),
|
|
260
|
+
progress: normalizeProgress(d.progress),
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
return { ...EMPTY_PROGRESS_DATA }
|
|
264
|
+
}
|
|
265
|
+
}
|