@agile-vibe-coding/avc 0.1.1 → 0.3.1

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.
Files changed (239) hide show
  1. package/cli/agent-loader.js +21 -0
  2. package/cli/agents/agent-selector.md +152 -0
  3. package/cli/agents/architecture-recommender.md +418 -0
  4. package/cli/agents/code-implementer.md +117 -0
  5. package/cli/agents/code-validator.md +80 -0
  6. package/cli/agents/context-reviewer-epic.md +101 -0
  7. package/cli/agents/context-reviewer-story.md +92 -0
  8. package/cli/agents/context-writer-epic.md +145 -0
  9. package/cli/agents/context-writer-story.md +111 -0
  10. package/cli/agents/database-deep-dive.md +470 -0
  11. package/cli/agents/database-recommender.md +634 -0
  12. package/cli/agents/doc-distributor.md +176 -0
  13. package/cli/agents/doc-writer-epic.md +42 -0
  14. package/cli/agents/doc-writer-story.md +43 -0
  15. package/cli/agents/documentation-updater.md +203 -0
  16. package/cli/agents/duplicate-detector.md +110 -0
  17. package/cli/agents/epic-story-decomposer.md +559 -0
  18. package/cli/agents/feature-context-generator.md +91 -0
  19. package/cli/agents/gap-checker-epic.md +52 -0
  20. package/cli/agents/impact-checker-story.md +51 -0
  21. package/cli/agents/migration-guide-generator.md +305 -0
  22. package/cli/agents/mission-scope-generator.md +143 -0
  23. package/cli/agents/mission-scope-validator.md +146 -0
  24. package/cli/agents/project-context-extractor.md +122 -0
  25. package/cli/agents/project-documentation-creator.json +226 -0
  26. package/cli/agents/project-documentation-creator.md +595 -0
  27. package/cli/agents/question-prefiller.md +269 -0
  28. package/cli/agents/refiner-epic.md +39 -0
  29. package/cli/agents/refiner-story.md +42 -0
  30. package/cli/agents/scaffolding-generator.md +99 -0
  31. package/cli/agents/seed-validator.md +71 -0
  32. package/cli/agents/story-doc-enricher.md +133 -0
  33. package/cli/agents/story-scope-reviewer.md +147 -0
  34. package/cli/agents/story-splitter.md +83 -0
  35. package/cli/agents/suggestion-business-analyst.md +88 -0
  36. package/cli/agents/suggestion-deployment-architect.md +263 -0
  37. package/cli/agents/suggestion-product-manager.md +129 -0
  38. package/cli/agents/suggestion-security-specialist.md +156 -0
  39. package/cli/agents/suggestion-technical-architect.md +269 -0
  40. package/cli/agents/suggestion-ux-researcher.md +93 -0
  41. package/cli/agents/task-subtask-decomposer.md +188 -0
  42. package/cli/agents/validator-documentation.json +183 -0
  43. package/cli/agents/validator-documentation.md +455 -0
  44. package/cli/agents/validator-selector.md +211 -0
  45. package/cli/ansi-colors.js +21 -0
  46. package/cli/api-reference-tool.js +368 -0
  47. package/cli/build-docs.js +29 -8
  48. package/cli/ceremony-history.js +369 -0
  49. package/cli/checks/catalog.json +76 -0
  50. package/cli/checks/code/quality.json +26 -0
  51. package/cli/checks/code/testing.json +14 -0
  52. package/cli/checks/code/traceability.json +26 -0
  53. package/cli/checks/cross-refs/epic.json +171 -0
  54. package/cli/checks/cross-refs/story.json +149 -0
  55. package/cli/checks/epic/api.json +114 -0
  56. package/cli/checks/epic/backend.json +126 -0
  57. package/cli/checks/epic/cloud.json +126 -0
  58. package/cli/checks/epic/data.json +102 -0
  59. package/cli/checks/epic/database.json +114 -0
  60. package/cli/checks/epic/developer.json +182 -0
  61. package/cli/checks/epic/devops.json +174 -0
  62. package/cli/checks/epic/frontend.json +162 -0
  63. package/cli/checks/epic/mobile.json +102 -0
  64. package/cli/checks/epic/qa.json +90 -0
  65. package/cli/checks/epic/security.json +184 -0
  66. package/cli/checks/epic/solution-architect.json +192 -0
  67. package/cli/checks/epic/test-architect.json +90 -0
  68. package/cli/checks/epic/ui.json +102 -0
  69. package/cli/checks/epic/ux.json +90 -0
  70. package/cli/checks/fixes/epic-fix-template.md +10 -0
  71. package/cli/checks/fixes/story-fix-template.md +10 -0
  72. package/cli/checks/story/api.json +186 -0
  73. package/cli/checks/story/backend.json +102 -0
  74. package/cli/checks/story/cloud.json +102 -0
  75. package/cli/checks/story/data.json +210 -0
  76. package/cli/checks/story/database.json +102 -0
  77. package/cli/checks/story/developer.json +168 -0
  78. package/cli/checks/story/devops.json +102 -0
  79. package/cli/checks/story/frontend.json +174 -0
  80. package/cli/checks/story/mobile.json +102 -0
  81. package/cli/checks/story/qa.json +210 -0
  82. package/cli/checks/story/security.json +198 -0
  83. package/cli/checks/story/solution-architect.json +230 -0
  84. package/cli/checks/story/test-architect.json +210 -0
  85. package/cli/checks/story/ui.json +102 -0
  86. package/cli/checks/story/ux.json +102 -0
  87. package/cli/coding-order.js +401 -0
  88. package/cli/command-logger.js +49 -12
  89. package/cli/components/static-output.js +63 -0
  90. package/cli/console-output-manager.js +94 -0
  91. package/cli/dependency-checker.js +72 -0
  92. package/cli/docs-sync.js +306 -0
  93. package/cli/epic-story-validator.js +659 -0
  94. package/cli/evaluation-prompts.js +1008 -0
  95. package/cli/execution-context.js +195 -0
  96. package/cli/generate-summary-table.js +340 -0
  97. package/cli/init-model-config.js +704 -0
  98. package/cli/init.js +1737 -278
  99. package/cli/kanban-server-manager.js +227 -0
  100. package/cli/llm-claude.js +150 -1
  101. package/cli/llm-gemini.js +109 -0
  102. package/cli/llm-local.js +493 -0
  103. package/cli/llm-mock.js +233 -0
  104. package/cli/llm-openai.js +454 -0
  105. package/cli/llm-provider.js +379 -3
  106. package/cli/llm-token-limits.js +211 -0
  107. package/cli/llm-verifier.js +662 -0
  108. package/cli/llm-xiaomi.js +143 -0
  109. package/cli/message-constants.js +49 -0
  110. package/cli/message-manager.js +334 -0
  111. package/cli/message-types.js +96 -0
  112. package/cli/messaging-api.js +291 -0
  113. package/cli/micro-check-fixer.js +335 -0
  114. package/cli/micro-check-runner.js +449 -0
  115. package/cli/micro-check-scorer.js +148 -0
  116. package/cli/micro-check-validator.js +538 -0
  117. package/cli/model-pricing.js +192 -0
  118. package/cli/model-query-engine.js +468 -0
  119. package/cli/model-recommendation-analyzer.js +495 -0
  120. package/cli/model-selector.js +270 -0
  121. package/cli/output-buffer.js +107 -0
  122. package/cli/process-manager.js +73 -2
  123. package/cli/prompt-logger.js +57 -0
  124. package/cli/repl-ink.js +4625 -1094
  125. package/cli/repl-old.js +3 -4
  126. package/cli/seed-processor.js +962 -0
  127. package/cli/sprint-planning-processor.js +4162 -0
  128. package/cli/template-processor.js +2149 -105
  129. package/cli/templates/project.md +25 -8
  130. package/cli/templates/vitepress-config.mts.template +5 -4
  131. package/cli/token-tracker.js +547 -0
  132. package/cli/tools/generate-story-validators.js +317 -0
  133. package/cli/tools/generate-validators.js +669 -0
  134. package/cli/update-checker.js +19 -17
  135. package/cli/update-notifier.js +4 -4
  136. package/cli/validation-router.js +667 -0
  137. package/cli/verification-tracker.js +563 -0
  138. package/cli/worktree-runner.js +654 -0
  139. package/kanban/README.md +386 -0
  140. package/kanban/client/README.md +205 -0
  141. package/kanban/client/components.json +20 -0
  142. package/kanban/client/dist/assets/index-D_KC5EQT.css +1 -0
  143. package/kanban/client/dist/assets/index-DjY5zqW7.js +351 -0
  144. package/kanban/client/dist/index.html +16 -0
  145. package/kanban/client/dist/vite.svg +1 -0
  146. package/kanban/client/index.html +15 -0
  147. package/kanban/client/package-lock.json +9442 -0
  148. package/kanban/client/package.json +44 -0
  149. package/kanban/client/postcss.config.js +6 -0
  150. package/kanban/client/public/vite.svg +1 -0
  151. package/kanban/client/src/App.jsx +651 -0
  152. package/kanban/client/src/components/ProjectFileEditorPopup.jsx +117 -0
  153. package/kanban/client/src/components/ceremony/AskArchPopup.jsx +420 -0
  154. package/kanban/client/src/components/ceremony/AskModelPopup.jsx +629 -0
  155. package/kanban/client/src/components/ceremony/CeremonyWorkflowModal.jsx +1133 -0
  156. package/kanban/client/src/components/ceremony/EpicStorySelectionModal.jsx +254 -0
  157. package/kanban/client/src/components/ceremony/ProviderSwitcherButton.jsx +290 -0
  158. package/kanban/client/src/components/ceremony/SponsorCallModal.jsx +686 -0
  159. package/kanban/client/src/components/ceremony/SprintPlanningModal.jsx +838 -0
  160. package/kanban/client/src/components/ceremony/steps/ArchitectureStep.jsx +150 -0
  161. package/kanban/client/src/components/ceremony/steps/CompleteStep.jsx +136 -0
  162. package/kanban/client/src/components/ceremony/steps/DatabaseStep.jsx +202 -0
  163. package/kanban/client/src/components/ceremony/steps/DeploymentStep.jsx +123 -0
  164. package/kanban/client/src/components/ceremony/steps/MissionStep.jsx +106 -0
  165. package/kanban/client/src/components/ceremony/steps/ReviewAnswersStep.jsx +329 -0
  166. package/kanban/client/src/components/ceremony/steps/RunningStep.jsx +249 -0
  167. package/kanban/client/src/components/kanban/CardDetailModal.jsx +646 -0
  168. package/kanban/client/src/components/kanban/EpicSection.jsx +146 -0
  169. package/kanban/client/src/components/kanban/FilterToolbar.jsx +222 -0
  170. package/kanban/client/src/components/kanban/GroupingSelector.jsx +63 -0
  171. package/kanban/client/src/components/kanban/KanbanBoard.jsx +211 -0
  172. package/kanban/client/src/components/kanban/KanbanCard.jsx +147 -0
  173. package/kanban/client/src/components/kanban/KanbanColumn.jsx +90 -0
  174. package/kanban/client/src/components/kanban/RefineWorkItemPopup.jsx +784 -0
  175. package/kanban/client/src/components/kanban/RunButton.jsx +162 -0
  176. package/kanban/client/src/components/kanban/SeedButton.jsx +176 -0
  177. package/kanban/client/src/components/layout/LoadingScreen.jsx +82 -0
  178. package/kanban/client/src/components/process/ProcessMonitorBar.jsx +80 -0
  179. package/kanban/client/src/components/settings/AgentEditorPopup.jsx +171 -0
  180. package/kanban/client/src/components/settings/AgentsTab.jsx +381 -0
  181. package/kanban/client/src/components/settings/ApiKeysTab.jsx +142 -0
  182. package/kanban/client/src/components/settings/CeremonyModelsTab.jsx +105 -0
  183. package/kanban/client/src/components/settings/CheckEditorPopup.jsx +507 -0
  184. package/kanban/client/src/components/settings/CostThresholdsTab.jsx +95 -0
  185. package/kanban/client/src/components/settings/ModelPricingTab.jsx +269 -0
  186. package/kanban/client/src/components/settings/OpenAIAuthSection.jsx +412 -0
  187. package/kanban/client/src/components/settings/ServersTab.jsx +121 -0
  188. package/kanban/client/src/components/settings/SettingsModal.jsx +84 -0
  189. package/kanban/client/src/components/stats/CostModal.jsx +384 -0
  190. package/kanban/client/src/components/ui/badge.jsx +27 -0
  191. package/kanban/client/src/components/ui/dialog.jsx +121 -0
  192. package/kanban/client/src/components/ui/tabs.jsx +85 -0
  193. package/kanban/client/src/hooks/__tests__/useGrouping.test.js +232 -0
  194. package/kanban/client/src/hooks/useGrouping.js +177 -0
  195. package/kanban/client/src/hooks/useWebSocket.js +120 -0
  196. package/kanban/client/src/lib/__tests__/api.test.js +196 -0
  197. package/kanban/client/src/lib/__tests__/status-grouping.test.js +94 -0
  198. package/kanban/client/src/lib/api.js +515 -0
  199. package/kanban/client/src/lib/status-grouping.js +154 -0
  200. package/kanban/client/src/lib/utils.js +11 -0
  201. package/kanban/client/src/main.jsx +10 -0
  202. package/kanban/client/src/store/__tests__/kanbanStore.test.js +164 -0
  203. package/kanban/client/src/store/ceremonyStore.js +172 -0
  204. package/kanban/client/src/store/filterStore.js +201 -0
  205. package/kanban/client/src/store/kanbanStore.js +123 -0
  206. package/kanban/client/src/store/processStore.js +65 -0
  207. package/kanban/client/src/store/sprintPlanningStore.js +33 -0
  208. package/kanban/client/src/styles/globals.css +59 -0
  209. package/kanban/client/tailwind.config.js +77 -0
  210. package/kanban/client/vite.config.js +28 -0
  211. package/kanban/client/vitest.config.js +28 -0
  212. package/kanban/dev-start.sh +47 -0
  213. package/kanban/package.json +12 -0
  214. package/kanban/server/index.js +537 -0
  215. package/kanban/server/routes/ceremony.js +454 -0
  216. package/kanban/server/routes/costs.js +163 -0
  217. package/kanban/server/routes/openai-oauth.js +366 -0
  218. package/kanban/server/routes/processes.js +50 -0
  219. package/kanban/server/routes/settings.js +736 -0
  220. package/kanban/server/routes/websocket.js +281 -0
  221. package/kanban/server/routes/work-items.js +487 -0
  222. package/kanban/server/services/CeremonyService.js +1441 -0
  223. package/kanban/server/services/FileSystemScanner.js +95 -0
  224. package/kanban/server/services/FileWatcher.js +144 -0
  225. package/kanban/server/services/HierarchyBuilder.js +196 -0
  226. package/kanban/server/services/ProcessRegistry.js +122 -0
  227. package/kanban/server/services/TaskRunnerService.js +261 -0
  228. package/kanban/server/services/WorkItemReader.js +123 -0
  229. package/kanban/server/services/WorkItemRefineService.js +510 -0
  230. package/kanban/server/start.js +49 -0
  231. package/kanban/server/utils/kanban-logger.js +132 -0
  232. package/kanban/server/utils/markdown.js +91 -0
  233. package/kanban/server/utils/status-grouping.js +107 -0
  234. package/kanban/server/workers/run-task-worker.js +121 -0
  235. package/kanban/server/workers/seed-worker.js +94 -0
  236. package/kanban/server/workers/sponsor-call-worker.js +92 -0
  237. package/kanban/server/workers/sprint-planning-worker.js +212 -0
  238. package/package.json +19 -7
  239. package/cli/agents/documentation.md +0 -302
@@ -0,0 +1,121 @@
1
+ import { useEffect } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+ import { X } from 'lucide-react';
4
+ import { cn } from '../../lib/utils';
5
+
6
+ /**
7
+ * Dialog Component (shadcn/ui style)
8
+ * Modal dialog with backdrop and animations
9
+ */
10
+ export function Dialog({ open, onOpenChange, children }) {
11
+ // Close on Escape key
12
+ useEffect(() => {
13
+ const handleEscape = (e) => {
14
+ if (e.key === 'Escape' && open) {
15
+ onOpenChange(false);
16
+ }
17
+ };
18
+
19
+ document.addEventListener('keydown', handleEscape);
20
+ return () => document.removeEventListener('keydown', handleEscape);
21
+ }, [open, onOpenChange]);
22
+
23
+ // Prevent body scroll when dialog is open
24
+ useEffect(() => {
25
+ if (open) {
26
+ document.body.style.overflow = 'hidden';
27
+ } else {
28
+ document.body.style.overflow = 'unset';
29
+ }
30
+
31
+ return () => {
32
+ document.body.style.overflow = 'unset';
33
+ };
34
+ }, [open]);
35
+
36
+ return (
37
+ <AnimatePresence>
38
+ {open && (
39
+ <>
40
+ {/* Backdrop */}
41
+ <motion.div
42
+ initial={{ opacity: 0 }}
43
+ animate={{ opacity: 1 }}
44
+ exit={{ opacity: 0 }}
45
+ onClick={() => onOpenChange(false)}
46
+ className="fixed inset-0 bg-black/50 z-50"
47
+ />
48
+
49
+ {/* Dialog Container */}
50
+ <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
51
+ {children}
52
+ </div>
53
+ </>
54
+ )}
55
+ </AnimatePresence>
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Dialog Content
61
+ */
62
+ export function DialogContent({ className, children, onClose }) {
63
+ return (
64
+ <motion.div
65
+ initial={{ opacity: 0, scale: 0.95, y: 20 }}
66
+ animate={{ opacity: 1, scale: 1, y: 0 }}
67
+ exit={{ opacity: 0, scale: 0.95, y: 20 }}
68
+ transition={{ duration: 0.2 }}
69
+ onClick={(e) => e.stopPropagation()}
70
+ className={cn(
71
+ 'relative bg-white rounded-lg shadow-xl max-w-4xl w-full h-[90vh] overflow-hidden',
72
+ 'flex flex-col',
73
+ className
74
+ )}
75
+ >
76
+ {/* Close button */}
77
+ <button
78
+ onClick={onClose}
79
+ className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 transition-opacity z-10"
80
+ >
81
+ <X className="h-5 w-5" />
82
+ <span className="sr-only">Close</span>
83
+ </button>
84
+
85
+ {children}
86
+ </motion.div>
87
+ );
88
+ }
89
+
90
+ /**
91
+ * Dialog Header
92
+ */
93
+ export function DialogHeader({ className, children }) {
94
+ return (
95
+ <div className={cn('flex flex-col space-y-1.5 px-6 pt-6 pb-4', className)}>
96
+ {children}
97
+ </div>
98
+ );
99
+ }
100
+
101
+ /**
102
+ * Dialog Title
103
+ */
104
+ export function DialogTitle({ className, children }) {
105
+ return (
106
+ <h2 className={cn('text-2xl font-semibold leading-none tracking-tight', className)}>
107
+ {children}
108
+ </h2>
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Dialog Description
114
+ */
115
+ export function DialogDescription({ className, children }) {
116
+ return (
117
+ <p className={cn('text-sm text-slate-600', className)}>
118
+ {children}
119
+ </p>
120
+ );
121
+ }
@@ -0,0 +1,85 @@
1
+ import { createContext, useContext, useEffect, useState } from 'react';
2
+ import { cn } from '../../lib/utils';
3
+
4
+ /**
5
+ * Tabs Component (shadcn/ui style)
6
+ * Tabbed interface with keyboard navigation
7
+ */
8
+
9
+ const TabsContext = createContext();
10
+
11
+ export function Tabs({ defaultValue, value, onValueChange, children, className }) {
12
+ const [selectedTab, setSelectedTab] = useState(value || defaultValue);
13
+
14
+ // Sync internal state when controlled value prop changes
15
+ useEffect(() => {
16
+ if (value !== undefined) {
17
+ setSelectedTab(value);
18
+ }
19
+ }, [value]);
20
+
21
+ const handleTabChange = (newValue) => {
22
+ setSelectedTab(newValue);
23
+ onValueChange?.(newValue);
24
+ };
25
+
26
+ return (
27
+ <TabsContext.Provider value={{ selectedTab, setSelectedTab: handleTabChange }}>
28
+ <div className={cn('w-full', className)}>{children}</div>
29
+ </TabsContext.Provider>
30
+ );
31
+ }
32
+
33
+ export function TabsList({ className, children }) {
34
+ return (
35
+ <div
36
+ className={cn(
37
+ 'inline-flex h-10 items-center justify-start rounded-md bg-slate-100 p-1 text-slate-600',
38
+ className
39
+ )}
40
+ >
41
+ {children}
42
+ </div>
43
+ );
44
+ }
45
+
46
+ export function TabsTrigger({ value, children, className }) {
47
+ const { selectedTab, setSelectedTab } = useContext(TabsContext);
48
+ const isSelected = selectedTab === value;
49
+
50
+ return (
51
+ <button
52
+ onClick={() => setSelectedTab(value)}
53
+ className={cn(
54
+ 'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5',
55
+ 'text-sm font-medium ring-offset-white transition-all',
56
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2',
57
+ 'disabled:pointer-events-none disabled:opacity-50',
58
+ isSelected
59
+ ? 'bg-white text-slate-900 shadow-sm'
60
+ : 'text-slate-600 hover:bg-slate-200',
61
+ className
62
+ )}
63
+ >
64
+ {children}
65
+ </button>
66
+ );
67
+ }
68
+
69
+ export function TabsContent({ value, children, className }) {
70
+ const { selectedTab } = useContext(TabsContext);
71
+
72
+ if (selectedTab !== value) return null;
73
+
74
+ return (
75
+ <div
76
+ className={cn(
77
+ 'mt-2 ring-offset-white focus-visible:outline-none focus-visible:ring-2',
78
+ 'focus-visible:ring-slate-950 focus-visible:ring-offset-2',
79
+ className
80
+ )}
81
+ >
82
+ {children}
83
+ </div>
84
+ );
85
+ }
@@ -0,0 +1,232 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderHook } from '@testing-library/react';
3
+ import { useGrouping } from '../useGrouping';
4
+
5
+ describe('useGrouping', () => {
6
+ const mockWorkItems = [
7
+ {
8
+ id: 'EPIC-001',
9
+ type: 'epic',
10
+ name: 'Epic 1',
11
+ status: 'implementing',
12
+ epicId: 'EPIC-001',
13
+ epicName: 'Epic 1',
14
+ },
15
+ {
16
+ id: 'STORY-001',
17
+ type: 'story',
18
+ name: 'Story 1',
19
+ status: 'ready',
20
+ epicId: 'EPIC-001',
21
+ epicName: 'Epic 1',
22
+ },
23
+ {
24
+ id: 'EPIC-002',
25
+ type: 'epic',
26
+ name: 'Epic 2',
27
+ status: 'planned',
28
+ epicId: 'EPIC-002',
29
+ epicName: 'Epic 2',
30
+ },
31
+ {
32
+ id: 'STORY-002',
33
+ type: 'story',
34
+ name: 'Story 2',
35
+ status: 'planned',
36
+ epicId: 'EPIC-002',
37
+ epicName: 'Epic 2',
38
+ },
39
+ {
40
+ id: 'TASK-001',
41
+ type: 'task',
42
+ name: 'Task 1',
43
+ status: 'completed',
44
+ epicId: null,
45
+ epicName: null,
46
+ },
47
+ ];
48
+
49
+ describe('groupBy: status', () => {
50
+ it('should group by status columns', () => {
51
+ const { result } = renderHook(() => useGrouping(mockWorkItems, 'status'));
52
+
53
+ expect(result.current.mode).toBe('columns');
54
+ expect(result.current.groups).toHaveLength(5); // 5 columns
55
+
56
+ const columnNames = result.current.groups.map((g) => g.name);
57
+ expect(columnNames).toEqual(['Backlog', 'Ready', 'In Progress', 'Review', 'Done']);
58
+ });
59
+
60
+ it('should distribute items to correct columns', () => {
61
+ const { result } = renderHook(() => useGrouping(mockWorkItems, 'status'));
62
+
63
+ const backlog = result.current.groups.find((g) => g.name === 'Backlog');
64
+ const ready = result.current.groups.find((g) => g.name === 'Ready');
65
+ const inProgress = result.current.groups.find((g) => g.name === 'In Progress');
66
+ const done = result.current.groups.find((g) => g.name === 'Done');
67
+
68
+ expect(backlog.items).toHaveLength(2); // EPIC-002, STORY-002 (planned)
69
+ expect(ready.items).toHaveLength(1); // STORY-001 (ready)
70
+ expect(inProgress.items).toHaveLength(1); // EPIC-001 (implementing)
71
+ expect(done.items).toHaveLength(1); // TASK-001 (completed)
72
+ });
73
+
74
+ it('should have column structure with id and name', () => {
75
+ const { result } = renderHook(() => useGrouping(mockWorkItems, 'status'));
76
+
77
+ result.current.groups.forEach((group) => {
78
+ expect(group).toHaveProperty('id');
79
+ expect(group).toHaveProperty('name');
80
+ expect(group).toHaveProperty('items');
81
+ expect(Array.isArray(group.items)).toBe(true);
82
+ });
83
+ });
84
+ });
85
+
86
+ describe('groupBy: epic', () => {
87
+ it('should group by epic with sections mode', () => {
88
+ const { result } = renderHook(() => useGrouping(mockWorkItems, 'epic'));
89
+
90
+ expect(result.current.mode).toBe('sections');
91
+ expect(result.current.groups.length).toBeGreaterThan(0);
92
+ });
93
+
94
+ it('should create section for each epic', () => {
95
+ const { result } = renderHook(() => useGrouping(mockWorkItems, 'epic'));
96
+
97
+ const epicNames = result.current.groups.map((g) => g.name);
98
+ expect(epicNames).toContain('Epic 1');
99
+ expect(epicNames).toContain('Epic 2');
100
+ });
101
+
102
+ it('should group items without epic into "No Epic" section', () => {
103
+ const { result } = renderHook(() => useGrouping(mockWorkItems, 'epic'));
104
+
105
+ const noEpicGroup = result.current.groups.find((g) => g.type === 'ungrouped');
106
+ expect(noEpicGroup).toBeDefined();
107
+ expect(noEpicGroup.items).toHaveLength(1); // TASK-001
108
+ });
109
+
110
+ it('should include epic object in group', () => {
111
+ const { result } = renderHook(() => useGrouping(mockWorkItems, 'epic'));
112
+
113
+ const epic1Group = result.current.groups.find((g) => g.name === 'Epic 1');
114
+ expect(epic1Group.epic).toBeDefined();
115
+ expect(epic1Group.epic.id).toBe('EPIC-001');
116
+ });
117
+
118
+ it('should distribute items into columns within each epic', () => {
119
+ const { result } = renderHook(() => useGrouping(mockWorkItems, 'epic'));
120
+
121
+ const epic1Group = result.current.groups.find((g) => g.name === 'Epic 1');
122
+ expect(epic1Group.columns).toBeDefined();
123
+ expect(epic1Group.columns.Ready).toHaveLength(1); // STORY-001
124
+ expect(epic1Group.columns['In Progress']).toHaveLength(1); // EPIC-001
125
+ });
126
+ });
127
+
128
+ describe('groupBy: type', () => {
129
+ it('should group by type with sections mode', () => {
130
+ const { result } = renderHook(() => useGrouping(mockWorkItems, 'type'));
131
+
132
+ expect(result.current.mode).toBe('sections');
133
+ });
134
+
135
+ it('should create section for each type', () => {
136
+ const { result } = renderHook(() => useGrouping(mockWorkItems, 'type'));
137
+
138
+ const typeNames = result.current.groups.map((g) => g.name);
139
+ expect(typeNames).toContain('Epics');
140
+ expect(typeNames).toContain('Stories');
141
+ expect(typeNames).toContain('Tasks');
142
+ });
143
+
144
+ it('should distribute items into correct type sections', () => {
145
+ const { result } = renderHook(() => useGrouping(mockWorkItems, 'type'));
146
+
147
+ const epicsGroup = result.current.groups.find((g) => g.name === 'Epics');
148
+ const storiesGroup = result.current.groups.find((g) => g.name === 'Stories');
149
+ const tasksGroup = result.current.groups.find((g) => g.name === 'Tasks');
150
+
151
+ expect(epicsGroup.items).toHaveLength(2); // EPIC-001, EPIC-002
152
+ expect(storiesGroup.items).toHaveLength(2); // STORY-001, STORY-002
153
+ expect(tasksGroup.items).toHaveLength(1); // TASK-001
154
+ });
155
+
156
+ it('should distribute items into columns within each type', () => {
157
+ const { result } = renderHook(() => useGrouping(mockWorkItems, 'type'));
158
+
159
+ const epicsGroup = result.current.groups.find((g) => g.name === 'Epics');
160
+ expect(epicsGroup.columns).toBeDefined();
161
+ expect(epicsGroup.columns.Backlog).toHaveLength(1); // EPIC-002 (planned)
162
+ expect(epicsGroup.columns['In Progress']).toHaveLength(1); // EPIC-001 (implementing)
163
+
164
+ const storiesGroup = result.current.groups.find((g) => g.name === 'Stories');
165
+ expect(storiesGroup.columns).toBeDefined();
166
+ expect(storiesGroup.columns.Backlog).toHaveLength(1); // STORY-002 (planned)
167
+ expect(storiesGroup.columns.Ready).toHaveLength(1); // STORY-001 (ready)
168
+ });
169
+ });
170
+
171
+ describe('memoization', () => {
172
+ it('should return same reference when inputs unchanged', () => {
173
+ const { result, rerender } = renderHook(
174
+ ({ items, groupBy }) => useGrouping(items, groupBy),
175
+ { initialProps: { items: mockWorkItems, groupBy: 'status' } }
176
+ );
177
+
178
+ const firstResult = result.current;
179
+ rerender({ items: mockWorkItems, groupBy: 'status' });
180
+ const secondResult = result.current;
181
+
182
+ expect(firstResult).toBe(secondResult);
183
+ });
184
+
185
+ it('should recalculate when groupBy changes', () => {
186
+ const { result, rerender } = renderHook(
187
+ ({ items, groupBy }) => useGrouping(items, groupBy),
188
+ { initialProps: { items: mockWorkItems, groupBy: 'status' } }
189
+ );
190
+
191
+ const statusResult = result.current;
192
+ rerender({ items: mockWorkItems, groupBy: 'epic' });
193
+ const epicResult = result.current;
194
+
195
+ expect(statusResult).not.toBe(epicResult);
196
+ expect(statusResult.mode).toBe('columns');
197
+ expect(epicResult.mode).toBe('sections');
198
+ });
199
+
200
+ it('should recalculate when items change', () => {
201
+ const { result, rerender } = renderHook(
202
+ ({ items, groupBy }) => useGrouping(items, groupBy),
203
+ { initialProps: { items: mockWorkItems, groupBy: 'status' } }
204
+ );
205
+
206
+ const firstResult = result.current;
207
+ const newItems = [...mockWorkItems, { id: 'NEW-001', status: 'ready' }];
208
+ rerender({ items: newItems, groupBy: 'status' });
209
+ const secondResult = result.current;
210
+
211
+ expect(firstResult).not.toBe(secondResult);
212
+ });
213
+ });
214
+
215
+ describe('edge cases', () => {
216
+ it('should handle empty work items array', () => {
217
+ const { result } = renderHook(() => useGrouping([], 'status'));
218
+
219
+ expect(result.current.groups).toHaveLength(5);
220
+ result.current.groups.forEach((group) => {
221
+ expect(group.items).toEqual([]);
222
+ });
223
+ });
224
+
225
+ it('should handle unknown groupBy value by defaulting to status', () => {
226
+ const { result } = renderHook(() => useGrouping(mockWorkItems, 'unknown'));
227
+
228
+ expect(result.current.mode).toBe('columns');
229
+ expect(result.current.groups).toHaveLength(5);
230
+ });
231
+ });
232
+ });
@@ -0,0 +1,177 @@
1
+ import { useMemo } from 'react';
2
+ import { groupItemsByColumn, COLUMN_ORDER } from '../lib/status-grouping';
3
+
4
+ /**
5
+ * Grouping Hook
6
+ * Handles different grouping strategies for work items
7
+ */
8
+ export function useGrouping(workItems, groupBy) {
9
+ return useMemo(() => {
10
+ switch (groupBy) {
11
+ case 'status':
12
+ return groupByStatus(workItems);
13
+ case 'epic':
14
+ return groupByEpic(workItems);
15
+ case 'type':
16
+ return groupByType(workItems);
17
+ case 'phase':
18
+ return groupByPhase(workItems);
19
+ default:
20
+ return groupByStatus(workItems);
21
+ }
22
+ }, [workItems, groupBy]);
23
+ }
24
+
25
+ /**
26
+ * Group by status (default kanban columns)
27
+ */
28
+ function groupByStatus(workItems) {
29
+ const grouped = groupItemsByColumn(workItems);
30
+
31
+ return {
32
+ mode: 'columns',
33
+ groups: COLUMN_ORDER.map((columnName) => ({
34
+ id: columnName,
35
+ name: columnName,
36
+ items: grouped[columnName] || [],
37
+ type: 'column',
38
+ })),
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Group by epic (hierarchical sections)
44
+ */
45
+ function groupByEpic(workItems) {
46
+ // Get all epics
47
+ const epics = workItems.filter((item) => item.type === 'epic');
48
+
49
+ // Group items by epic
50
+ const groups = epics.map((epic) => {
51
+ // Get all descendants of this epic
52
+ const epicItems = workItems.filter(
53
+ (item) => item.epicId === epic.id
54
+ );
55
+
56
+ // Group epic's items by column
57
+ const columns = groupItemsByColumn(epicItems);
58
+
59
+ return {
60
+ id: epic.id,
61
+ name: epic.name,
62
+ epic: epic,
63
+ items: epicItems,
64
+ columns: columns,
65
+ type: 'epic',
66
+ };
67
+ });
68
+
69
+ // Add ungrouped items (items without an epic)
70
+ const ungroupedItems = workItems.filter(
71
+ (item) => !item.epicId && item.type !== 'epic'
72
+ );
73
+
74
+ if (ungroupedItems.length > 0) {
75
+ const columns = groupItemsByColumn(ungroupedItems);
76
+ groups.push({
77
+ id: 'ungrouped',
78
+ name: 'No Epic',
79
+ items: ungroupedItems,
80
+ columns: columns,
81
+ type: 'ungrouped',
82
+ });
83
+ }
84
+
85
+ return {
86
+ mode: 'sections',
87
+ groups,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Group by type (separate boards for each type)
93
+ */
94
+ function groupByType(workItems) {
95
+ const types = [
96
+ { id: 'epic', name: 'Epics' },
97
+ { id: 'story', name: 'Stories' },
98
+ { id: 'task', name: 'Tasks' },
99
+ { id: 'subtask', name: 'Subtasks' },
100
+ ];
101
+
102
+ const groups = types.map((type) => {
103
+ const typeItems = workItems.filter((item) => item.type === type.id);
104
+ const columns = groupItemsByColumn(typeItems);
105
+
106
+ return {
107
+ id: type.id,
108
+ name: type.name,
109
+ items: typeItems,
110
+ columns: columns,
111
+ type: 'type',
112
+ };
113
+ });
114
+
115
+ // Filter out empty groups
116
+ return {
117
+ mode: 'sections',
118
+ groups: groups.filter((group) => group.items.length > 0),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Group by implementation phase (coding order)
124
+ */
125
+ function groupByPhase(workItems) {
126
+ // Collect all phases from metadata
127
+ const phaseMap = new Map(); // phase number → items
128
+ const unphased = [];
129
+
130
+ for (const item of workItems) {
131
+ const phase = item.metadata?.codingPhase;
132
+ if (phase != null) {
133
+ if (!phaseMap.has(phase)) phaseMap.set(phase, []);
134
+ phaseMap.get(phase).push(item);
135
+ } else {
136
+ unphased.push(item);
137
+ }
138
+ }
139
+
140
+ // Sort phases numerically
141
+ const sortedPhases = [...phaseMap.entries()].sort((a, b) => a[0] - b[0]);
142
+
143
+ const groups = sortedPhases.map(([phase, items]) => {
144
+ // Sort items within phase by codingOrder
145
+ items.sort((a, b) => (a.metadata?.codingOrder ?? 0) - (b.metadata?.codingOrder ?? 0));
146
+ const columns = groupItemsByColumn(items);
147
+
148
+ // Find epic names in this phase for the label
149
+ const epicNames = [...new Set(items.filter(i => i.type === 'epic').map(i => i.name))];
150
+ const label = epicNames.length > 0
151
+ ? `Phase ${phase}: ${epicNames.join(', ')}`
152
+ : `Phase ${phase}`;
153
+
154
+ return {
155
+ id: `phase-${phase}`,
156
+ name: label,
157
+ items,
158
+ columns,
159
+ type: 'phase',
160
+ };
161
+ });
162
+
163
+ if (unphased.length > 0) {
164
+ groups.push({
165
+ id: 'unphased',
166
+ name: 'No Phase',
167
+ items: unphased,
168
+ columns: groupItemsByColumn(unphased),
169
+ type: 'ungrouped',
170
+ });
171
+ }
172
+
173
+ return {
174
+ mode: 'sections',
175
+ groups,
176
+ };
177
+ }