zyflow 0.6.4

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 (705) hide show
  1. package/.claude-flow/metrics/agent-metrics.json +1 -0
  2. package/.claude-flow/metrics/performance.json +87 -0
  3. package/.claude-flow/metrics/system-metrics.json +4370 -0
  4. package/.claude-flow/metrics/task-metrics.json +10 -0
  5. package/.claude-plugin/marketplace.json +18 -0
  6. package/.claude-plugin/plugin.json +17 -0
  7. package/.gitleaks.toml +69 -0
  8. package/.hive-mind/config/queens.json +59 -0
  9. package/.hive-mind/config/workers.json +72 -0
  10. package/.hive-mind/config.json +111 -0
  11. package/.hive-mind/hive.db +0 -0
  12. package/.hive-mind/hive.db-shm +0 -0
  13. package/.hive-mind/hive.db-wal +0 -0
  14. package/.leann/indexes/zyflow/documents.ids.txt +2078 -0
  15. package/.leann/indexes/zyflow/documents.index +0 -0
  16. package/.leann/indexes/zyflow/documents.leann.meta.json +25 -0
  17. package/.leann/indexes/zyflow/documents.leann.passages.idx +0 -0
  18. package/.leann/indexes/zyflow/documents.leann.passages.jsonl +2078 -0
  19. package/.mcp.json +41 -0
  20. package/.moai-backups/20260126_231508/.mcp.json +11 -0
  21. package/.moai-backups/20260126_231508/backup_metadata.json +34 -0
  22. package/.moai-backups/20260129_145438/.mcp.json +41 -0
  23. package/.moai-backups/20260129_145438/backup_metadata.json +53 -0
  24. package/.moai-backups/20260129_145504/.mcp.json +41 -0
  25. package/.moai-backups/20260129_145504/backup_metadata.json +20 -0
  26. package/.moai-backups/20260201_140004/.mcp.json +41 -0
  27. package/.moai-backups/20260201_140004/backup_metadata.json +51 -0
  28. package/.moai-backups/backup/.mcp.json +12 -0
  29. package/.moai-backups/settings-backup/settings.local.json +61 -0
  30. package/.pre-commit-config.yaml +74 -0
  31. package/.prettierignore +3 -0
  32. package/.prettierrc +7 -0
  33. package/.scannerwork/.sonar_lock +0 -0
  34. package/.scannerwork/report-task.txt +6 -0
  35. package/.serena/project.yml +105 -0
  36. package/.shadcn-admin-ref/.env.example +1 -0
  37. package/.shadcn-admin-ref/.prettierignore +18 -0
  38. package/.shadcn-admin-ref/.prettierrc +50 -0
  39. package/.shadcn-admin-ref/LICENSE +21 -0
  40. package/.shadcn-admin-ref/components.json +21 -0
  41. package/.shadcn-admin-ref/cz.yaml +7 -0
  42. package/.shadcn-admin-ref/eslint.config.js +59 -0
  43. package/.shadcn-admin-ref/index.html +80 -0
  44. package/.shadcn-admin-ref/knip.config.ts +8 -0
  45. package/.shadcn-admin-ref/netlify.toml +4 -0
  46. package/.shadcn-admin-ref/package.json +83 -0
  47. package/.shadcn-admin-ref/public/images/favicon.png +0 -0
  48. package/.shadcn-admin-ref/public/images/favicon.svg +4 -0
  49. package/.shadcn-admin-ref/public/images/favicon_light.png +0 -0
  50. package/.shadcn-admin-ref/public/images/favicon_light.svg +1 -0
  51. package/.shadcn-admin-ref/public/images/shadcn-admin.png +0 -0
  52. package/.shadcn-admin-ref/src/assets/brand-icons/icon-discord.tsx +28 -0
  53. package/.shadcn-admin-ref/src/assets/brand-icons/icon-docker.tsx +33 -0
  54. package/.shadcn-admin-ref/src/assets/brand-icons/icon-facebook.tsx +25 -0
  55. package/.shadcn-admin-ref/src/assets/brand-icons/icon-figma.tsx +27 -0
  56. package/.shadcn-admin-ref/src/assets/brand-icons/icon-github.tsx +25 -0
  57. package/.shadcn-admin-ref/src/assets/brand-icons/icon-gitlab.tsx +25 -0
  58. package/.shadcn-admin-ref/src/assets/brand-icons/icon-gmail.tsx +28 -0
  59. package/.shadcn-admin-ref/src/assets/brand-icons/icon-medium.tsx +30 -0
  60. package/.shadcn-admin-ref/src/assets/brand-icons/icon-notion.tsx +28 -0
  61. package/.shadcn-admin-ref/src/assets/brand-icons/icon-skype.tsx +26 -0
  62. package/.shadcn-admin-ref/src/assets/brand-icons/icon-slack.tsx +28 -0
  63. package/.shadcn-admin-ref/src/assets/brand-icons/icon-stripe.tsx +25 -0
  64. package/.shadcn-admin-ref/src/assets/brand-icons/icon-telegram.tsx +25 -0
  65. package/.shadcn-admin-ref/src/assets/brand-icons/icon-trello.tsx +27 -0
  66. package/.shadcn-admin-ref/src/assets/brand-icons/icon-whatsapp.tsx +26 -0
  67. package/.shadcn-admin-ref/src/assets/brand-icons/icon-zoom.tsx +26 -0
  68. package/.shadcn-admin-ref/src/assets/brand-icons/index.ts +16 -0
  69. package/.shadcn-admin-ref/src/assets/clerk-full-logo.tsx +41 -0
  70. package/.shadcn-admin-ref/src/assets/clerk-logo.tsx +23 -0
  71. package/.shadcn-admin-ref/src/assets/custom/icon-dir.tsx +110 -0
  72. package/.shadcn-admin-ref/src/assets/custom/icon-layout-compact.tsx +131 -0
  73. package/.shadcn-admin-ref/src/assets/custom/icon-layout-default.tsx +124 -0
  74. package/.shadcn-admin-ref/src/assets/custom/icon-layout-full.tsx +100 -0
  75. package/.shadcn-admin-ref/src/assets/custom/icon-sidebar-floating.tsx +82 -0
  76. package/.shadcn-admin-ref/src/assets/custom/icon-sidebar-inset.tsx +58 -0
  77. package/.shadcn-admin-ref/src/assets/custom/icon-sidebar-sidebar.tsx +53 -0
  78. package/.shadcn-admin-ref/src/assets/custom/icon-theme-dark.tsx +79 -0
  79. package/.shadcn-admin-ref/src/assets/custom/icon-theme-light.tsx +78 -0
  80. package/.shadcn-admin-ref/src/assets/custom/icon-theme-system.tsx +116 -0
  81. package/.shadcn-admin-ref/src/assets/logo.tsx +24 -0
  82. package/.shadcn-admin-ref/src/components/coming-soon.tsx +16 -0
  83. package/.shadcn-admin-ref/src/components/command-menu.tsx +91 -0
  84. package/.shadcn-admin-ref/src/components/config-drawer.tsx +354 -0
  85. package/.shadcn-admin-ref/src/components/confirm-dialog.tsx +67 -0
  86. package/.shadcn-admin-ref/src/components/data-table/bulk-actions.tsx +213 -0
  87. package/.shadcn-admin-ref/src/components/data-table/column-header.tsx +74 -0
  88. package/.shadcn-admin-ref/src/components/data-table/faceted-filter.tsx +146 -0
  89. package/.shadcn-admin-ref/src/components/data-table/index.ts +6 -0
  90. package/.shadcn-admin-ref/src/components/data-table/pagination.tsx +130 -0
  91. package/.shadcn-admin-ref/src/components/data-table/toolbar.tsx +85 -0
  92. package/.shadcn-admin-ref/src/components/data-table/view-options.tsx +56 -0
  93. package/.shadcn-admin-ref/src/components/date-picker.tsx +51 -0
  94. package/.shadcn-admin-ref/src/components/layout/app-sidebar.tsx +37 -0
  95. package/.shadcn-admin-ref/src/components/layout/app-title.tsx +64 -0
  96. package/.shadcn-admin-ref/src/components/layout/authenticated-layout.tsx +42 -0
  97. package/.shadcn-admin-ref/src/components/layout/data/sidebar-data.ts +205 -0
  98. package/.shadcn-admin-ref/src/components/layout/header.tsx +50 -0
  99. package/.shadcn-admin-ref/src/components/layout/main.tsx +27 -0
  100. package/.shadcn-admin-ref/src/components/layout/nav-group.tsx +185 -0
  101. package/.shadcn-admin-ref/src/components/layout/nav-user.tsx +124 -0
  102. package/.shadcn-admin-ref/src/components/layout/team-switcher.tsx +86 -0
  103. package/.shadcn-admin-ref/src/components/layout/top-nav.tsx +67 -0
  104. package/.shadcn-admin-ref/src/components/layout/types.ts +44 -0
  105. package/.shadcn-admin-ref/src/components/learn-more.tsx +44 -0
  106. package/.shadcn-admin-ref/src/components/long-text.tsx +84 -0
  107. package/.shadcn-admin-ref/src/components/navigation-progress.tsx +25 -0
  108. package/.shadcn-admin-ref/src/components/password-input.tsx +42 -0
  109. package/.shadcn-admin-ref/src/components/profile-dropdown.tsx +75 -0
  110. package/.shadcn-admin-ref/src/components/search.tsx +37 -0
  111. package/.shadcn-admin-ref/src/components/select-dropdown.tsx +62 -0
  112. package/.shadcn-admin-ref/src/components/sign-out-dialog.tsx +38 -0
  113. package/.shadcn-admin-ref/src/components/skip-to-main.tsx +10 -0
  114. package/.shadcn-admin-ref/src/components/theme-switch.tsx +58 -0
  115. package/.shadcn-admin-ref/src/components/ui/alert-dialog.tsx +154 -0
  116. package/.shadcn-admin-ref/src/components/ui/alert.tsx +65 -0
  117. package/.shadcn-admin-ref/src/components/ui/avatar.tsx +50 -0
  118. package/.shadcn-admin-ref/src/components/ui/badge.tsx +45 -0
  119. package/.shadcn-admin-ref/src/components/ui/button.tsx +58 -0
  120. package/.shadcn-admin-ref/src/components/ui/calendar.tsx +210 -0
  121. package/.shadcn-admin-ref/src/components/ui/card.tsx +91 -0
  122. package/.shadcn-admin-ref/src/components/ui/checkbox.tsx +29 -0
  123. package/.shadcn-admin-ref/src/components/ui/collapsible.tsx +31 -0
  124. package/.shadcn-admin-ref/src/components/ui/command.tsx +181 -0
  125. package/.shadcn-admin-ref/src/components/ui/dialog.tsx +142 -0
  126. package/.shadcn-admin-ref/src/components/ui/dropdown-menu.tsx +254 -0
  127. package/.shadcn-admin-ref/src/components/ui/form.tsx +164 -0
  128. package/.shadcn-admin-ref/src/components/ui/input-otp.tsx +74 -0
  129. package/.shadcn-admin-ref/src/components/ui/input.tsx +20 -0
  130. package/.shadcn-admin-ref/src/components/ui/label.tsx +23 -0
  131. package/.shadcn-admin-ref/src/components/ui/popover.tsx +45 -0
  132. package/.shadcn-admin-ref/src/components/ui/radio-group.tsx +42 -0
  133. package/.shadcn-admin-ref/src/components/ui/scroll-area.tsx +65 -0
  134. package/.shadcn-admin-ref/src/components/ui/select.tsx +182 -0
  135. package/.shadcn-admin-ref/src/components/ui/separator.tsx +25 -0
  136. package/.shadcn-admin-ref/src/components/ui/sheet.tsx +136 -0
  137. package/.shadcn-admin-ref/src/components/ui/sidebar.tsx +728 -0
  138. package/.shadcn-admin-ref/src/components/ui/skeleton.tsx +13 -0
  139. package/.shadcn-admin-ref/src/components/ui/sonner.tsx +21 -0
  140. package/.shadcn-admin-ref/src/components/ui/switch.tsx +28 -0
  141. package/.shadcn-admin-ref/src/components/ui/table.tsx +113 -0
  142. package/.shadcn-admin-ref/src/components/ui/tabs.tsx +63 -0
  143. package/.shadcn-admin-ref/src/components/ui/textarea.tsx +17 -0
  144. package/.shadcn-admin-ref/src/components/ui/tooltip.tsx +60 -0
  145. package/.shadcn-admin-ref/src/config/fonts.ts +19 -0
  146. package/.shadcn-admin-ref/src/context/direction-provider.tsx +61 -0
  147. package/.shadcn-admin-ref/src/context/font-provider.tsx +58 -0
  148. package/.shadcn-admin-ref/src/context/layout-provider.tsx +85 -0
  149. package/.shadcn-admin-ref/src/context/search-provider.tsx +46 -0
  150. package/.shadcn-admin-ref/src/context/theme-provider.tsx +110 -0
  151. package/.shadcn-admin-ref/src/features/apps/data/apps.tsx +110 -0
  152. package/.shadcn-admin-ref/src/features/apps/index.tsx +179 -0
  153. package/.shadcn-admin-ref/src/features/auth/auth-layout.tsx +19 -0
  154. package/.shadcn-admin-ref/src/features/auth/forgot-password/components/forgot-password-form.tsx +82 -0
  155. package/.shadcn-admin-ref/src/features/auth/forgot-password/index.tsx +44 -0
  156. package/.shadcn-admin-ref/src/features/auth/otp/components/otp-form.tsx +100 -0
  157. package/.shadcn-admin-ref/src/features/auth/otp/index.tsx +44 -0
  158. package/.shadcn-admin-ref/src/features/auth/sign-in/assets/dashboard-dark.png +0 -0
  159. package/.shadcn-admin-ref/src/features/auth/sign-in/assets/dashboard-light.png +0 -0
  160. package/.shadcn-admin-ref/src/features/auth/sign-in/components/user-auth-form.tsx +150 -0
  161. package/.shadcn-admin-ref/src/features/auth/sign-in/index.tsx +51 -0
  162. package/.shadcn-admin-ref/src/features/auth/sign-in/sign-in-2.tsx +69 -0
  163. package/.shadcn-admin-ref/src/features/auth/sign-up/components/sign-up-form.tsx +143 -0
  164. package/.shadcn-admin-ref/src/features/auth/sign-up/index.tsx +57 -0
  165. package/.shadcn-admin-ref/src/features/chats/components/new-chat.tsx +127 -0
  166. package/.shadcn-admin-ref/src/features/chats/data/chat-types.ts +4 -0
  167. package/.shadcn-admin-ref/src/features/chats/data/convo.json +309 -0
  168. package/.shadcn-admin-ref/src/features/chats/index.tsx +349 -0
  169. package/.shadcn-admin-ref/src/features/dashboard/components/analytics-chart.tsx +77 -0
  170. package/.shadcn-admin-ref/src/features/dashboard/components/analytics.tsx +189 -0
  171. package/.shadcn-admin-ref/src/features/dashboard/components/overview.tsx +82 -0
  172. package/.shadcn-admin-ref/src/features/dashboard/components/recent-sales.tsx +83 -0
  173. package/.shadcn-admin-ref/src/features/dashboard/index.tsx +220 -0
  174. package/.shadcn-admin-ref/src/features/errors/forbidden.tsx +25 -0
  175. package/.shadcn-admin-ref/src/features/errors/general-error.tsx +36 -0
  176. package/.shadcn-admin-ref/src/features/errors/maintenance-error.tsx +19 -0
  177. package/.shadcn-admin-ref/src/features/errors/not-found-error.tsx +25 -0
  178. package/.shadcn-admin-ref/src/features/errors/unauthorized-error.tsx +25 -0
  179. package/.shadcn-admin-ref/src/features/settings/account/account-form.tsx +173 -0
  180. package/.shadcn-admin-ref/src/features/settings/account/index.tsx +14 -0
  181. package/.shadcn-admin-ref/src/features/settings/appearance/appearance-form.tsx +162 -0
  182. package/.shadcn-admin-ref/src/features/settings/appearance/index.tsx +14 -0
  183. package/.shadcn-admin-ref/src/features/settings/components/content-section.tsx +22 -0
  184. package/.shadcn-admin-ref/src/features/settings/components/sidebar-nav.tsx +84 -0
  185. package/.shadcn-admin-ref/src/features/settings/display/display-form.tsx +121 -0
  186. package/.shadcn-admin-ref/src/features/settings/display/index.tsx +13 -0
  187. package/.shadcn-admin-ref/src/features/settings/index.tsx +74 -0
  188. package/.shadcn-admin-ref/src/features/settings/notifications/index.tsx +13 -0
  189. package/.shadcn-admin-ref/src/features/settings/notifications/notifications-form.tsx +220 -0
  190. package/.shadcn-admin-ref/src/features/settings/profile/index.tsx +13 -0
  191. package/.shadcn-admin-ref/src/features/settings/profile/profile-form.tsx +177 -0
  192. package/.shadcn-admin-ref/src/features/tasks/components/data-table-bulk-actions.tsx +193 -0
  193. package/.shadcn-admin-ref/src/features/tasks/components/data-table-row-actions.tsx +83 -0
  194. package/.shadcn-admin-ref/src/features/tasks/components/tasks-columns.tsx +123 -0
  195. package/.shadcn-admin-ref/src/features/tasks/components/tasks-dialogs.tsx +72 -0
  196. package/.shadcn-admin-ref/src/features/tasks/components/tasks-import-dialog.tsx +110 -0
  197. package/.shadcn-admin-ref/src/features/tasks/components/tasks-multi-delete-dialog.tsx +95 -0
  198. package/.shadcn-admin-ref/src/features/tasks/components/tasks-mutate-drawer.tsx +212 -0
  199. package/.shadcn-admin-ref/src/features/tasks/components/tasks-primary-buttons.tsx +21 -0
  200. package/.shadcn-admin-ref/src/features/tasks/components/tasks-provider.tsx +36 -0
  201. package/.shadcn-admin-ref/src/features/tasks/components/tasks-table.tsx +197 -0
  202. package/.shadcn-admin-ref/src/features/tasks/data/data.tsx +77 -0
  203. package/.shadcn-admin-ref/src/features/tasks/data/schema.ts +13 -0
  204. package/.shadcn-admin-ref/src/features/tasks/data/tasks.ts +29 -0
  205. package/.shadcn-admin-ref/src/features/tasks/index.tsx +41 -0
  206. package/.shadcn-admin-ref/src/features/users/components/data-table-bulk-actions.tsx +139 -0
  207. package/.shadcn-admin-ref/src/features/users/components/data-table-row-actions.tsx +63 -0
  208. package/.shadcn-admin-ref/src/features/users/components/users-action-dialog.tsx +326 -0
  209. package/.shadcn-admin-ref/src/features/users/components/users-columns.tsx +138 -0
  210. package/.shadcn-admin-ref/src/features/users/components/users-delete-dialog.tsx +81 -0
  211. package/.shadcn-admin-ref/src/features/users/components/users-dialogs.tsx +51 -0
  212. package/.shadcn-admin-ref/src/features/users/components/users-invite-dialog.tsx +150 -0
  213. package/.shadcn-admin-ref/src/features/users/components/users-multi-delete-dialog.tsx +95 -0
  214. package/.shadcn-admin-ref/src/features/users/components/users-primary-buttons.tsx +21 -0
  215. package/.shadcn-admin-ref/src/features/users/components/users-provider.tsx +36 -0
  216. package/.shadcn-admin-ref/src/features/users/components/users-table.tsx +194 -0
  217. package/.shadcn-admin-ref/src/features/users/data/data.ts +35 -0
  218. package/.shadcn-admin-ref/src/features/users/data/schema.ts +32 -0
  219. package/.shadcn-admin-ref/src/features/users/data/users.ts +33 -0
  220. package/.shadcn-admin-ref/src/features/users/index.tsx +47 -0
  221. package/.shadcn-admin-ref/src/hooks/use-dialog-state.tsx +18 -0
  222. package/.shadcn-admin-ref/src/hooks/use-mobile.tsx +19 -0
  223. package/.shadcn-admin-ref/src/hooks/use-table-url-state.ts +219 -0
  224. package/.shadcn-admin-ref/src/lib/cookies.ts +43 -0
  225. package/.shadcn-admin-ref/src/lib/handle-server-error.ts +24 -0
  226. package/.shadcn-admin-ref/src/lib/show-submitted-data.tsx +15 -0
  227. package/.shadcn-admin-ref/src/lib/utils.ts +60 -0
  228. package/.shadcn-admin-ref/src/main.tsx +107 -0
  229. package/.shadcn-admin-ref/src/routeTree.gen.ts +719 -0
  230. package/.shadcn-admin-ref/src/routes/(auth)/forgot-password.tsx +6 -0
  231. package/.shadcn-admin-ref/src/routes/(auth)/otp.tsx +6 -0
  232. package/.shadcn-admin-ref/src/routes/(auth)/sign-in-2.tsx +6 -0
  233. package/.shadcn-admin-ref/src/routes/(auth)/sign-in.tsx +12 -0
  234. package/.shadcn-admin-ref/src/routes/(auth)/sign-up.tsx +6 -0
  235. package/.shadcn-admin-ref/src/routes/(errors)/401.tsx +6 -0
  236. package/.shadcn-admin-ref/src/routes/(errors)/403.tsx +6 -0
  237. package/.shadcn-admin-ref/src/routes/(errors)/404.tsx +6 -0
  238. package/.shadcn-admin-ref/src/routes/(errors)/500.tsx +6 -0
  239. package/.shadcn-admin-ref/src/routes/(errors)/503.tsx +6 -0
  240. package/.shadcn-admin-ref/src/routes/__root.tsx +30 -0
  241. package/.shadcn-admin-ref/src/routes/_authenticated/apps/index.tsx +17 -0
  242. package/.shadcn-admin-ref/src/routes/_authenticated/chats/index.tsx +6 -0
  243. package/.shadcn-admin-ref/src/routes/_authenticated/errors/$error.tsx +45 -0
  244. package/.shadcn-admin-ref/src/routes/_authenticated/help-center/index.tsx +6 -0
  245. package/.shadcn-admin-ref/src/routes/_authenticated/index.tsx +6 -0
  246. package/.shadcn-admin-ref/src/routes/_authenticated/route.tsx +6 -0
  247. package/.shadcn-admin-ref/src/routes/_authenticated/settings/account.tsx +6 -0
  248. package/.shadcn-admin-ref/src/routes/_authenticated/settings/appearance.tsx +6 -0
  249. package/.shadcn-admin-ref/src/routes/_authenticated/settings/display.tsx +6 -0
  250. package/.shadcn-admin-ref/src/routes/_authenticated/settings/index.tsx +6 -0
  251. package/.shadcn-admin-ref/src/routes/_authenticated/settings/notifications.tsx +6 -0
  252. package/.shadcn-admin-ref/src/routes/_authenticated/settings/route.tsx +6 -0
  253. package/.shadcn-admin-ref/src/routes/_authenticated/tasks/index.tsx +23 -0
  254. package/.shadcn-admin-ref/src/routes/_authenticated/users/index.tsx +32 -0
  255. package/.shadcn-admin-ref/src/routes/clerk/(auth)/route.tsx +60 -0
  256. package/.shadcn-admin-ref/src/routes/clerk/(auth)/sign-in.tsx +14 -0
  257. package/.shadcn-admin-ref/src/routes/clerk/(auth)/sign-up.tsx +9 -0
  258. package/.shadcn-admin-ref/src/routes/clerk/_authenticated/route.tsx +6 -0
  259. package/.shadcn-admin-ref/src/routes/clerk/_authenticated/user-management.tsx +184 -0
  260. package/.shadcn-admin-ref/src/routes/clerk/route.tsx +135 -0
  261. package/.shadcn-admin-ref/src/stores/auth-store.ts +53 -0
  262. package/.shadcn-admin-ref/src/styles/index.css +87 -0
  263. package/.shadcn-admin-ref/src/styles/theme.css +102 -0
  264. package/.shadcn-admin-ref/src/tanstack-table.d.ts +10 -0
  265. package/.shadcn-admin-ref/src/vite-env.d.ts +1 -0
  266. package/.swarm/memory.db +0 -0
  267. package/.swarm/memory.db-shm +0 -0
  268. package/.swarm/memory.db-wal +0 -0
  269. package/.zyflow/cli-settings.json +30 -0
  270. package/.zyflow/db.sqlite +0 -0
  271. package/.zyflow/logs/add-gitdiagram-integration/1633-1765491505852.json +10 -0
  272. package/.zyflow/logs/add-gitdiagram-integration/1633-1765491622627.json +10 -0
  273. package/.zyflow/logs/add-gitdiagram-integration/1633-1765491794652.json +10 -0
  274. package/.zyflow/logs/add-gitdiagram-integration/1633-1765491890392.json +10 -0
  275. package/.zyflow/logs/add-gitdiagram-integration/1633-1765494002879.json +10 -0
  276. package/.zyflow/logs/add-gitdiagram-integration/1633-1765494183887.json +10 -0
  277. package/.zyflow/logs/add-gitdiagram-integration/1633-1765494342052.json +10 -0
  278. package/.zyflow/logs/add-gitdiagram-integration/1633-1765494387244.json +10 -0
  279. package/.zyflow/logs/add-gitdiagram-integration/1633-1765494387245.json +10 -0
  280. package/.zyflow/logs/add-gitdiagram-integration/1633-1765494606176.json +10 -0
  281. package/.zyflow/logs/add-gitdiagram-integration/1633-1765495967542.json +16 -0
  282. package/.zyflow/logs/add-gitdiagram-integration/1633-1765495967629.json +16 -0
  283. package/.zyflow/logs/add-gitdiagram-integration/1633-1765497861143.json +16 -0
  284. package/.zyflow/logs/add-gitdiagram-integration/1633-1765497861870.json +20 -0
  285. package/.zyflow/logs/add-gitdiagram-integration/1633-1765498021377.json +18 -0
  286. package/.zyflow/logs/add-gitdiagram-integration/1633-1765498021660.json +18 -0
  287. package/.zyflow/logs/add-gitdiagram-integration/1633-1765503255525.json +13 -0
  288. package/.zyflow/logs/add-gitdiagram-integration/1633-1765503256018.json +13 -0
  289. package/.zyflow/logs/add-gitdiagram-integration/1633-1765504009102.json +16 -0
  290. package/.zyflow/logs/add-gitdiagram-integration/1633-1765504492051.json +18 -0
  291. package/.zyflow/logs/add-gitdiagram-integration/1633-1765504946437.json +16 -0
  292. package/.zyflow/logs/add-gitdiagram-integration/1633-1765504946640.json +16 -0
  293. package/.zyflow/logs/add-gitdiagram-integration/1634-1765505950215.json +16 -0
  294. package/.zyflow/logs/add-gitdiagram-integration/1634-1765505950948.json +18 -0
  295. package/.zyflow/logs/add-gitdiagram-integration/1635-1765505971712.json +18 -0
  296. package/.zyflow/logs/add-gitdiagram-integration/1635-1765505971976.json +18 -0
  297. package/.zyflow/logs/add-gitdiagram-integration/1636-1765505986208.json +18 -0
  298. package/.zyflow/logs/add-gitdiagram-integration/1636-1765505986620.json +16 -0
  299. package/.zyflow/logs/integrate-claude-flow/3580-1765996816612.json +10 -0
  300. package/.zyflow/logs/integrate-claude-flow/3580-1766014825819.json +10 -0
  301. package/.zyflow/logs/integrate-claude-flow/3580-1766015183794.json +12 -0
  302. package/.zyflow/logs/integrate-claude-flow/3580-1766015474608.json +12 -0
  303. package/.zyflow/logs/integrate-claude-flow/3581-1766016502824.json +63 -0
  304. package/.zyflow/logs/integrate-claude-flow/3581-1766016576008.json +60 -0
  305. package/.zyflow/logs/integrate-claude-flow/3582-1766022737754.json +110 -0
  306. package/.zyflow/logs/integrate-claude-flow/3582-1766022809327.json +135 -0
  307. package/.zyflow/sessions.json +242 -0
  308. package/.zyflow/settings.json +6 -0
  309. package/.zyflow/tasks.db +0 -0
  310. package/.zyflow/tasks.db-shm +0 -0
  311. package/.zyflow/tasks.db-wal +0 -0
  312. package/.zyflow/zyflow.sqlite +0 -0
  313. package/Dockerfile +82 -0
  314. package/LICENSE +21 -0
  315. package/README.md +506 -0
  316. package/claude-flow +34 -0
  317. package/components.json +21 -0
  318. package/config/ports.ts +28 -0
  319. package/docker-compose.yml +52 -0
  320. package/eslint.config.js +34 -0
  321. package/index.html +19 -0
  322. package/logs/mcp-error.log +55 -0
  323. package/logs/mcp-out.log +0 -0
  324. package/logs/pm2-error.log +0 -0
  325. package/logs/pm2-out.log +265 -0
  326. package/logs/py-error.log +22 -0
  327. package/logs/py-out.log +0 -0
  328. package/logs/server-error.log +11000 -0
  329. package/logs/server-out.log +8117 -0
  330. package/logs/vite-error.log +404 -0
  331. package/logs/vite-out.log +311 -0
  332. package/mcp-server/agent-tools.ts +375 -0
  333. package/mcp-server/cli-models.ts +193 -0
  334. package/mcp-server/context.ts +110 -0
  335. package/mcp-server/diagram-tools.ts +341 -0
  336. package/mcp-server/index.ts +2014 -0
  337. package/mcp-server/integration-tools.ts +909 -0
  338. package/mcp-server/moai-spec-tools.ts +416 -0
  339. package/mcp-server/parser.ts +422 -0
  340. package/mcp-server/post-task-runner.ts +253 -0
  341. package/mcp-server/post-task-types.ts +426 -0
  342. package/mcp-server/quarantine-manager.ts +479 -0
  343. package/mcp-server/report-generator.ts +386 -0
  344. package/mcp-server/task-tools.ts +619 -0
  345. package/mcp-server/trigger-config.ts +288 -0
  346. package/mcp-server/trigger-router.ts +305 -0
  347. package/mcp-server/triggers/event-listener.ts +331 -0
  348. package/mcp-server/triggers/git-hooks.ts +283 -0
  349. package/mcp-server/triggers/scheduler.ts +289 -0
  350. package/mcp-server/types.ts +55 -0
  351. package/memory/claude-flow@alpha-data.json +5 -0
  352. package/nginx/zyflow.conf +144 -0
  353. package/openspec/config.yaml +78 -0
  354. package/openspec-backup.tar.gz +0 -0
  355. package/package.json +154 -0
  356. package/packages/gitdiagram-core/.claude-flow/metrics/agent-metrics.json +1 -0
  357. package/packages/gitdiagram-core/.claude-flow/metrics/performance.json +87 -0
  358. package/packages/gitdiagram-core/.claude-flow/metrics/task-metrics.json +10 -0
  359. package/packages/gitdiagram-core/package.json +41 -0
  360. package/packages/gitdiagram-core/src/file-tree.ts +272 -0
  361. package/packages/gitdiagram-core/src/generator.ts +283 -0
  362. package/packages/gitdiagram-core/src/index.ts +78 -0
  363. package/packages/gitdiagram-core/src/llm-adapter.ts +235 -0
  364. package/packages/gitdiagram-core/src/mermaid-utils.ts +304 -0
  365. package/packages/gitdiagram-core/src/prompts.ts +281 -0
  366. package/packages/zyflow-parser/package.json +34 -0
  367. package/packages/zyflow-parser/src/index.ts +26 -0
  368. package/packages/zyflow-parser/src/moai-parser.ts +603 -0
  369. package/packages/zyflow-parser/src/moai-types.ts +110 -0
  370. package/packages/zyflow-remote-plugin/.claude-flow/metrics/agent-metrics.json +1 -0
  371. package/packages/zyflow-remote-plugin/.claude-flow/metrics/performance.json +87 -0
  372. package/packages/zyflow-remote-plugin/.claude-flow/metrics/task-metrics.json +10 -0
  373. package/packages/zyflow-remote-plugin/package.json +31 -0
  374. package/packages/zyflow-remote-plugin/src/index.ts +71 -0
  375. package/packages/zyflow-remote-plugin/src/remote-config.ts +232 -0
  376. package/packages/zyflow-remote-plugin/src/router.ts +535 -0
  377. package/packages/zyflow-remote-plugin/src/ssh-config-parser.ts +123 -0
  378. package/packages/zyflow-remote-plugin/src/ssh-manager.ts +598 -0
  379. package/packages/zyflow-remote-plugin/src/types.ts +149 -0
  380. package/plugin/manifest.json +26 -0
  381. package/plugin/package.json +13 -0
  382. package/public/favicon.svg +4 -0
  383. package/server/adk/agents/error-analyzer.ts +223 -0
  384. package/server/adk/agents/fix-generator.ts +187 -0
  385. package/server/adk/agents/pr-agent.ts +264 -0
  386. package/server/adk/agents/validator.ts +187 -0
  387. package/server/adk/config.ts +43 -0
  388. package/server/adk/index.ts +69 -0
  389. package/server/adk/integration.ts +297 -0
  390. package/server/adk/orchestrator.ts +405 -0
  391. package/server/adk/tools/build-tools.ts +290 -0
  392. package/server/adk/tools/file-tools.ts +351 -0
  393. package/server/adk/tools/git-tools.ts +280 -0
  394. package/server/adk/tools/github-tools.ts +249 -0
  395. package/server/agents/agent-monitor.ts +416 -0
  396. package/server/agents/alert-integration.ts +312 -0
  397. package/server/agents/error-analyzer.ts +472 -0
  398. package/server/agents/error-detector.ts +442 -0
  399. package/server/agents/fix-generator.ts +421 -0
  400. package/server/agents/fix-validator.ts +428 -0
  401. package/server/agents/merge-policy.ts +362 -0
  402. package/server/agents/pr-workflow.ts +476 -0
  403. package/server/agents/prompts/error-analysis.ts +393 -0
  404. package/server/ai/gemini-client.ts +499 -0
  405. package/server/ai/index.ts +317 -0
  406. package/server/ai/types.ts +137 -0
  407. package/server/app.ts +3693 -0
  408. package/server/archive-manager.ts +604 -0
  409. package/server/backlog/index.ts +7 -0
  410. package/server/backlog/migration.ts +331 -0
  411. package/server/backlog/parser.ts +323 -0
  412. package/server/backlog/sync.ts +325 -0
  413. package/server/change-log.ts +868 -0
  414. package/server/claude-flow/index.ts +12 -0
  415. package/server/claude-flow/prompt-builder.ts +407 -0
  416. package/server/claude-flow/types.ts +33 -0
  417. package/server/cli-adapter/index.ts +11 -0
  418. package/server/cli-adapter/process-manager.ts +612 -0
  419. package/server/cli-adapter/profile-manager.ts +286 -0
  420. package/server/cli-adapter/routes.ts +561 -0
  421. package/server/cli-adapter/types.ts +226 -0
  422. package/server/config.d.ts +18 -0
  423. package/server/config.js +79 -0
  424. package/server/config.ts +262 -0
  425. package/server/flow-sync.ts +543 -0
  426. package/server/git/change-workflow.ts +446 -0
  427. package/server/git/commands.ts +370 -0
  428. package/server/git/github.ts +247 -0
  429. package/server/git/index.ts +1202 -0
  430. package/server/git/status.ts +322 -0
  431. package/server/index.ts +136 -0
  432. package/server/integrations/crypto.ts +142 -0
  433. package/server/integrations/db/client.ts +169 -0
  434. package/server/integrations/db/schema.ts +167 -0
  435. package/server/integrations/env-parser.ts +365 -0
  436. package/server/integrations/index.ts +101 -0
  437. package/server/integrations/keychain.ts +239 -0
  438. package/server/integrations/local/file-utils.ts +383 -0
  439. package/server/integrations/local/index.ts +64 -0
  440. package/server/integrations/local/resolver.ts +439 -0
  441. package/server/integrations/local/types.ts +122 -0
  442. package/server/integrations/routes.ts +1100 -0
  443. package/server/integrations/service-patterns.ts +771 -0
  444. package/server/integrations/services/accounts.ts +356 -0
  445. package/server/integrations/services/env-import.ts +279 -0
  446. package/server/integrations/services/projects.ts +552 -0
  447. package/server/integrations/services/system-import.ts +1110 -0
  448. package/server/migrations/ears-generator.ts +491 -0
  449. package/server/migrations/gherkin-generator.ts +605 -0
  450. package/server/migrations/index.ts +73 -0
  451. package/server/migrations/migrate-spec-format.ts +492 -0
  452. package/server/migrations/openspec-parser.ts +542 -0
  453. package/server/migrations/tag-generator.ts +474 -0
  454. package/server/moai-specs.ts +487 -0
  455. package/server/moai-watcher.ts +145 -0
  456. package/server/parser-debug.ts +37 -0
  457. package/server/parser-utils.ts +316 -0
  458. package/server/parser.d.ts +17 -0
  459. package/server/parser.js +221 -0
  460. package/server/parser.ts +342 -0
  461. package/server/remote-watcher.ts +367 -0
  462. package/server/replay-engine.ts +915 -0
  463. package/server/routes/alerts.ts +1028 -0
  464. package/server/routes/changes.ts +812 -0
  465. package/server/routes/docs.ts +898 -0
  466. package/server/routes/flow.ts +2814 -0
  467. package/server/routes/global-chat.ts +162 -0
  468. package/server/routes/leann.ts +327 -0
  469. package/server/routes/projects.ts +1282 -0
  470. package/server/routes/search.ts +266 -0
  471. package/server/routes/specs.ts +482 -0
  472. package/server/routes/webhooks.ts +579 -0
  473. package/server/server/parser.js +265 -0
  474. package/server/services/githubActionsPoller.ts +797 -0
  475. package/server/services/slackNotifier.ts +476 -0
  476. package/server/src/types/index.js +1 -0
  477. package/server/sync-tasks.ts +741 -0
  478. package/server/tasks/cli/commands.ts +269 -0
  479. package/server/tasks/cli/index.ts +152 -0
  480. package/server/tasks/core/search.ts +81 -0
  481. package/server/tasks/core/task.ts +307 -0
  482. package/server/tasks/db/client.ts +1008 -0
  483. package/server/tasks/db/schema.ts +572 -0
  484. package/server/tasks/index.ts +24 -0
  485. package/server/tasks.db +0 -0
  486. package/server/types/archive.ts +136 -0
  487. package/server/types/change-log.ts +643 -0
  488. package/server/types/spec.ts +188 -0
  489. package/server/unified-spec-scanner.ts +753 -0
  490. package/server/utils/crypto.ts +179 -0
  491. package/server/utils/webhook-verify.ts +216 -0
  492. package/server/watcher.ts +132 -0
  493. package/server/websocket.ts +99 -0
  494. package/server-output.log +6 -0
  495. package/sonar-project.properties +18 -0
  496. package/src/App.tsx +386 -0
  497. package/src/api/client.ts +346 -0
  498. package/src/api/error-interceptor.ts +366 -0
  499. package/src/api/errors.ts +123 -0
  500. package/src/api/flow.ts +233 -0
  501. package/src/api/offline-queue.ts +351 -0
  502. package/src/api/retry-logic.ts +233 -0
  503. package/src/components/OfflineModeBanner.tsx +159 -0
  504. package/src/components/SSEStatusIndicator.tsx +194 -0
  505. package/src/components/agent/AgentChat.tsx +243 -0
  506. package/src/components/agent/AgentPage.tsx +182 -0
  507. package/src/components/agent/AgentSidebar.tsx +231 -0
  508. package/src/components/agent/index.ts +7 -0
  509. package/src/components/alerts/AlertCenter.tsx +239 -0
  510. package/src/components/alerts/AlertDashboard.tsx +211 -0
  511. package/src/components/alerts/AlertDetail.tsx +474 -0
  512. package/src/components/alerts/AlertList.tsx +113 -0
  513. package/src/components/alerts/AlertSettings.tsx +336 -0
  514. package/src/components/alerts/index.ts +5 -0
  515. package/src/components/chat/ChatPanel.tsx +642 -0
  516. package/src/components/chat/index.ts +1 -0
  517. package/src/components/cli/AddCustomCLIDialog.tsx +210 -0
  518. package/src/components/cli/CLISelector.tsx +187 -0
  519. package/src/components/cli/index.ts +8 -0
  520. package/src/components/dashboard/ArchivedChangeList.tsx +102 -0
  521. package/src/components/dashboard/ArchivedChangeViewer.tsx +184 -0
  522. package/src/components/dashboard/ArchivedChangesPage.tsx +31 -0
  523. package/src/components/dashboard/ChangeList.tsx +86 -0
  524. package/src/components/dashboard/ThemeToggle.tsx +33 -0
  525. package/src/components/diagram/DiagramViewer.tsx +256 -0
  526. package/src/components/diagram/MermaidRenderer.tsx +163 -0
  527. package/src/components/diagram/ProjectDiagramTab.tsx +161 -0
  528. package/src/components/diagram/index.ts +13 -0
  529. package/src/components/errors/ErrorBoundary.tsx +276 -0
  530. package/src/components/errors/ErrorFallback.tsx +198 -0
  531. package/src/components/errors/ErrorToast.tsx +221 -0
  532. package/src/components/flow/BacklogView.tsx +1142 -0
  533. package/src/components/flow/ChangeDetail.tsx +475 -0
  534. package/src/components/flow/ChangeItem.tsx +230 -0
  535. package/src/components/flow/ChangeList.tsx +92 -0
  536. package/src/components/flow/ExecutionHistoryDialog.tsx +224 -0
  537. package/src/components/flow/FlowContent.tsx +212 -0
  538. package/src/components/flow/FlowPage.tsx +9 -0
  539. package/src/components/flow/PipelineBar.tsx +214 -0
  540. package/src/components/flow/ProjectDashboard.tsx +222 -0
  541. package/src/components/flow/SpecDetail.tsx +138 -0
  542. package/src/components/flow/SpecDetailTabs.tsx +176 -0
  543. package/src/components/flow/SpecItem.tsx +93 -0
  544. package/src/components/flow/SpecProgressBar.tsx +47 -0
  545. package/src/components/flow/StageContent.tsx +620 -0
  546. package/src/components/flow/StandaloneTasks.tsx +960 -0
  547. package/src/components/flow/TaskExecutionDialog.tsx +1204 -0
  548. package/src/components/flow/index.ts +9 -0
  549. package/src/components/flow/task-execution/AgentSlider.tsx +37 -0
  550. package/src/components/flow/task-execution/ConsensusSettings.tsx +129 -0
  551. package/src/components/flow/task-execution/ExecutionOutput.tsx +398 -0
  552. package/src/components/flow/task-execution/ModelSelector.tsx +134 -0
  553. package/src/components/flow/task-execution/ProviderSelector.tsx +137 -0
  554. package/src/components/flow/task-execution/RecommendationBanner.tsx +71 -0
  555. package/src/components/flow/task-execution/StatusBadge.tsx +43 -0
  556. package/src/components/flow/task-execution/StrategySelector.tsx +48 -0
  557. package/src/components/flow/task-execution/SwarmSummary.tsx +55 -0
  558. package/src/components/flow/task-execution/index.ts +14 -0
  559. package/src/components/flow/task-execution/types.ts +56 -0
  560. package/src/components/git/ChangeWorkflowDialog.tsx +582 -0
  561. package/src/components/git/ConflictResolutionDialog.tsx +398 -0
  562. package/src/components/git/GitBranchSelector.tsx +212 -0
  563. package/src/components/git/GitCommitDialog.tsx +254 -0
  564. package/src/components/git/GitStatusBadge.tsx +148 -0
  565. package/src/components/git/GitSyncButton.tsx +128 -0
  566. package/src/components/git/RemoteStatusBanner.tsx +143 -0
  567. package/src/components/git/index.ts +9 -0
  568. package/src/components/integrations/EnvImportDialog.tsx +524 -0
  569. package/src/components/integrations/EnvironmentDialog.tsx +227 -0
  570. package/src/components/integrations/IntegrationBadges.tsx +91 -0
  571. package/src/components/integrations/IntegrationsSettings.tsx +55 -0
  572. package/src/components/integrations/ProjectIntegrations.tsx +481 -0
  573. package/src/components/integrations/ServiceAccountDialog.tsx +422 -0
  574. package/src/components/integrations/ServiceAccountList.tsx +305 -0
  575. package/src/components/integrations/SystemImportDialog.tsx +436 -0
  576. package/src/components/integrations/TestAccountDialog.tsx +162 -0
  577. package/src/components/integrations/index.ts +6 -0
  578. package/src/components/layout/AppSidebar.tsx +284 -0
  579. package/src/components/layout/FlowSidebar.tsx +435 -0
  580. package/src/components/layout/GlobalCommandPalette.tsx +410 -0
  581. package/src/components/layout/MenuBar.tsx +227 -0
  582. package/src/components/layout/StatusBar.tsx +226 -0
  583. package/src/components/monitoring/ErrorDashboard.tsx +274 -0
  584. package/src/components/monitoring/ErrorDetailPanel.tsx +200 -0
  585. package/src/components/monitoring/ErrorFilters.tsx +219 -0
  586. package/src/components/monitoring/ErrorHistoryList.tsx +141 -0
  587. package/src/components/monitoring/ErrorStats.tsx +249 -0
  588. package/src/components/remote/RemoteFileBrowser.tsx +249 -0
  589. package/src/components/remote/RemoteServerDialog.tsx +234 -0
  590. package/src/components/remote/RemoteServerList.tsx +366 -0
  591. package/src/components/remote/index.ts +7 -0
  592. package/src/components/settings/CLISettings.tsx +522 -0
  593. package/src/components/settings/CustomCLIDialog.tsx +548 -0
  594. package/src/components/settings/IntegrationsSettings.tsx +51 -0
  595. package/src/components/settings/ProjectSettings.tsx +441 -0
  596. package/src/components/settings/ProjectsSettings.tsx +541 -0
  597. package/src/components/settings/SearchSettings.tsx +272 -0
  598. package/src/components/settings/SettingsPage.tsx +68 -0
  599. package/src/components/settings/index.ts +5 -0
  600. package/src/components/swarm/ExecutionPanel.tsx +284 -0
  601. package/src/components/swarm/LogViewer.tsx +196 -0
  602. package/src/components/swarm/ProgressIndicator.tsx +111 -0
  603. package/src/components/swarm/index.ts +3 -0
  604. package/src/components/tasks/ArchiveTable.tsx +203 -0
  605. package/src/components/tasks/KanbanBoard.tsx +264 -0
  606. package/src/components/tasks/TaskCard.tsx +138 -0
  607. package/src/components/tasks/TaskColumn.tsx +81 -0
  608. package/src/components/tasks/TaskDialog.tsx +274 -0
  609. package/src/components/tasks/index.ts +5 -0
  610. package/src/components/tasks/types.ts +43 -0
  611. package/src/components/ui/alert-dialog.tsx +154 -0
  612. package/src/components/ui/alert.tsx +65 -0
  613. package/src/components/ui/badge.tsx +45 -0
  614. package/src/components/ui/button.tsx +58 -0
  615. package/src/components/ui/card.tsx +91 -0
  616. package/src/components/ui/checkbox.tsx +29 -0
  617. package/src/components/ui/collapsible.tsx +31 -0
  618. package/src/components/ui/command.tsx +184 -0
  619. package/src/components/ui/confirm-dialog.tsx +55 -0
  620. package/src/components/ui/dialog.tsx +142 -0
  621. package/src/components/ui/dropdown-menu.tsx +254 -0
  622. package/src/components/ui/input.tsx +20 -0
  623. package/src/components/ui/label.tsx +22 -0
  624. package/src/components/ui/markdown.tsx +100 -0
  625. package/src/components/ui/progress.tsx +27 -0
  626. package/src/components/ui/resizable-sidebar.tsx +156 -0
  627. package/src/components/ui/resizable.tsx +54 -0
  628. package/src/components/ui/right-resizable-sidebar.tsx +158 -0
  629. package/src/components/ui/scroll-area.tsx +64 -0
  630. package/src/components/ui/select.tsx +185 -0
  631. package/src/components/ui/separator.tsx +25 -0
  632. package/src/components/ui/sheet.tsx +136 -0
  633. package/src/components/ui/sidebar.tsx +726 -0
  634. package/src/components/ui/skeleton.tsx +13 -0
  635. package/src/components/ui/slider.tsx +56 -0
  636. package/src/components/ui/switch.tsx +29 -0
  637. package/src/components/ui/table.tsx +113 -0
  638. package/src/components/ui/tabs.tsx +63 -0
  639. package/src/components/ui/textarea.tsx +17 -0
  640. package/src/components/ui/tooltip.tsx +60 -0
  641. package/src/config/api.ts +83 -0
  642. package/src/constants/error-codes.ts +255 -0
  643. package/src/constants/stages.ts +27 -0
  644. package/src/context/ErrorContext.tsx +185 -0
  645. package/src/context/theme-provider.tsx +63 -0
  646. package/src/hooks/use-mobile.tsx +19 -0
  647. package/src/hooks/useAI.ts +206 -0
  648. package/src/hooks/useAgentSession.ts +431 -0
  649. package/src/hooks/useAlerts.ts +935 -0
  650. package/src/hooks/useArchivedChanges.ts +39 -0
  651. package/src/hooks/useAsyncError.ts +45 -0
  652. package/src/hooks/useChangeGit.ts +727 -0
  653. package/src/hooks/useChanges.ts +20 -0
  654. package/src/hooks/useClaude.ts +130 -0
  655. package/src/hooks/useDocs.ts +182 -0
  656. package/src/hooks/useErrorDashboard.ts +243 -0
  657. package/src/hooks/useErrorHandler.ts +150 -0
  658. package/src/hooks/useExecutionHistory.ts +55 -0
  659. package/src/hooks/useFlowChanges.ts +850 -0
  660. package/src/hooks/useFlowItems.ts +205 -0
  661. package/src/hooks/useGit.ts +427 -0
  662. package/src/hooks/useHideCompletedSpecs.ts +15 -0
  663. package/src/hooks/useInstance.ts +40 -0
  664. package/src/hooks/useIntegrations.ts +737 -0
  665. package/src/hooks/useLeannStatus.ts +93 -0
  666. package/src/hooks/useNetworkStatus.ts +167 -0
  667. package/src/hooks/useProjects.ts +353 -0
  668. package/src/hooks/useRemoteServers.ts +383 -0
  669. package/src/hooks/useSSEConnection.ts +346 -0
  670. package/src/hooks/useSpecs.ts +39 -0
  671. package/src/hooks/useSwarm.ts +462 -0
  672. package/src/hooks/useTasks.ts +137 -0
  673. package/src/hooks/useURLSync.ts +122 -0
  674. package/src/hooks/useWebSocket.ts +262 -0
  675. package/src/lib/utils.ts +121 -0
  676. package/src/main.tsx +22 -0
  677. package/src/stores/errorStore.ts +301 -0
  678. package/src/stores/offlineStore.ts +266 -0
  679. package/src/stores/sseStore.ts +247 -0
  680. package/src/stores/useHideCompletedStore.ts +21 -0
  681. package/src/styles/index.css +87 -0
  682. package/src/styles/theme.css +102 -0
  683. package/src/types/ai.ts +191 -0
  684. package/src/types/errors.ts +253 -0
  685. package/src/types/flow.ts +382 -0
  686. package/src/types/index.ts +614 -0
  687. package/src/utils/error-logger.ts +399 -0
  688. package/src/utils/error-statistics.ts +305 -0
  689. package/src/utils/logger.ts +280 -0
  690. package/src/utils/task-routing.ts +795 -0
  691. package/src/vite-env.d.ts +1 -0
  692. package/test-results/.last-run.json +4 -0
  693. package/tmp/check-docker-final.ts +48 -0
  694. package/tmp/check-docker-tasks.ts +58 -0
  695. package/tmp/check-docker-tasks2.ts +48 -0
  696. package/tmp/check-docker-tasks3.ts +42 -0
  697. package/tmp/check-mobile-tasks.ts +57 -0
  698. package/tmp/check-zywiki-tasks.ts +49 -0
  699. package/tmp/sync-mobile.ts +11 -0
  700. package/tmp/sync-zywiki.ts +68 -0
  701. package/tmp/test-docker-parser.ts +15 -0
  702. package/tmp/test-mobile-parser.ts +28 -0
  703. package/tmp/test-parser.ts +27 -0
  704. package/tmp/test-unnumbered.ts +35 -0
  705. package/zyflow.db +0 -0
@@ -0,0 +1,2814 @@
1
+ /**
2
+ * Flow Router
3
+ *
4
+ * Flow Changes 및 Tasks 관련 API 라우터 (DB 기반)
5
+ */
6
+
7
+ import { Router } from 'express'
8
+ import { readdir, readFile, writeFile, access, unlink, rename, stat } from 'fs/promises'
9
+ import { existsSync } from 'fs'
10
+ import { join, basename } from 'path'
11
+ import { exec } from 'child_process'
12
+ import { promisify } from 'util'
13
+ import { loadConfig, getActiveProject, getProjectById } from '../config.js'
14
+ import { parseTasksFile } from '../parser.js'
15
+ import { parsePlanFile, parseAcceptanceFile } from '@zyflow/parser'
16
+ import { initDb } from '../tasks/index.js'
17
+ import { getSqlite } from '../tasks/db/client.js'
18
+ import type { Stage, ChangeStatus, TaskOrigin } from '../tasks/db/schema.js'
19
+ import { emit } from '../websocket.js'
20
+
21
+ /**
22
+ * Count markdown checklists in content for progress calculation.
23
+ * Used as fallback when no TAG-XXX format tags are found.
24
+ */
25
+ function countMarkdownChecklists(content: string): { completed: number; total: number; percentage: number } | null {
26
+ const checklistRegex = /^[\s]*-\s*\[([ xX])\]/gm
27
+ let completed = 0
28
+ let total = 0
29
+
30
+ let match
31
+ while ((match = checklistRegex.exec(content)) !== null) {
32
+ total++
33
+ if (match[1].toLowerCase() === 'x') {
34
+ completed++
35
+ }
36
+ }
37
+
38
+ if (total === 0) return null
39
+ return { completed, total, percentage: Math.round((completed / total) * 100) }
40
+ }
41
+
42
+ // Remote plugin is optional - only load if installed
43
+ interface RemotePlugin {
44
+ getRemoteServerById: (id: string) => Promise<unknown>
45
+ listDirectory: (server: unknown, path: string) => Promise<{ entries: Array<{ type: string; name: string }> }>
46
+ readRemoteFile: (server: unknown, path: string) => Promise<string>
47
+ }
48
+
49
+ let remotePlugin: RemotePlugin | null = null
50
+
51
+ async function getRemotePlugin(): Promise<RemotePlugin | null> {
52
+ if (remotePlugin) return remotePlugin
53
+ try {
54
+ const mod = await import('@zyflow/remote-plugin')
55
+ remotePlugin = mod as unknown as RemotePlugin
56
+ return remotePlugin
57
+ } catch {
58
+ return null
59
+ }
60
+ }
61
+
62
+ import {
63
+ syncBacklogToDb,
64
+ saveTaskToBacklogFile,
65
+ generateNewBacklogTaskId,
66
+ getBacklogPath,
67
+ ensureBacklogDir,
68
+ type BacklogTask,
69
+ // Migration
70
+ previewMigration,
71
+ migrateInboxToBacklog,
72
+ migrateSelectedInboxTasks,
73
+ } from '../backlog/index.js'
74
+ import { serializeBacklogTask, generateBacklogFilename } from '../backlog/parser.js'
75
+ import { syncChangeTasksFromFile, syncChangeTasksForProject, syncRemoteChangeTasksForProject } from '../sync-tasks.js'
76
+ import { scanMoaiSpecs as syncMoaiSpecsToDb } from '../flow-sync.js'
77
+ import { getMoaiSpec, scanMoaiSpecs, scanRemoteMoaiSpecs } from '../moai-specs.js'
78
+
79
+ const execAsync = promisify(exec)
80
+
81
+ export const flowRouter = Router()
82
+
83
+ // Stage order for progress calculation
84
+ const STAGES: Stage[] = ['spec', 'changes', 'task', 'code', 'test', 'commit', 'docs']
85
+
86
+ // Initialize task database
87
+ async function initTaskDb() {
88
+ initDb()
89
+ }
90
+
91
+ // Helper to get paths for active project
92
+ async function getProjectPaths() {
93
+ const project = await getActiveProject()
94
+ if (!project) {
95
+ return null
96
+ }
97
+ return {
98
+ projectPath: project.path,
99
+ openspecDir: join(project.path, 'openspec', 'changes'),
100
+ specsDir: join(project.path, 'openspec', 'specs'),
101
+ plansDir: join(project.path, '.zyflow', 'plans'),
102
+ }
103
+ }
104
+
105
+ // Helper to get project info for a specific change (supports remote projects)
106
+ async function getProjectForChange(changeId: string) {
107
+ initDb()
108
+ const config = await loadConfig()
109
+ const sqlite = getSqlite()
110
+
111
+ // Find the change in database to get project_id
112
+ const change = sqlite
113
+ .prepare('SELECT project_id FROM changes WHERE id = ?')
114
+ .get(changeId) as { project_id: string } | undefined
115
+
116
+ if (!change) return null
117
+
118
+ // Find the project from config
119
+ const project = config.projects.find((p) => p.id === change.project_id)
120
+ return project || null
121
+ }
122
+
123
+ // Helper to find archive folder path for a change (remote projects use date-prefixed folders)
124
+ async function findRemoteArchivePath(
125
+ plugin: RemotePlugin,
126
+ server: unknown,
127
+ archiveDir: string,
128
+ changeId: string
129
+ ): Promise<string | null> {
130
+ try {
131
+ const result = await plugin.listDirectory(server, archiveDir)
132
+ // Archive folders are named like "2026-01-17-change-id" or just "change-id"
133
+ const matchingFolder = result.entries.find(
134
+ (entry) => entry.type === 'directory' && (entry.name === changeId || entry.name.endsWith(`-${changeId}`))
135
+ )
136
+ return matchingFolder ? `${archiveDir}/${matchingFolder.name}` : null
137
+ } catch {
138
+ return null
139
+ }
140
+ }
141
+
142
+ // Helper: Get stages for a change
143
+ function getChangeStages(changeId: string, projectId?: string) {
144
+ const sqlite = getSqlite()
145
+ const stages: Record<Stage, { total: number; completed: number; tasks: unknown[] }> = {
146
+ spec: { total: 0, completed: 0, tasks: [] },
147
+ changes: { total: 0, completed: 0, tasks: [] },
148
+ task: { total: 0, completed: 0, tasks: [] },
149
+ code: { total: 0, completed: 0, tasks: [] },
150
+ test: { total: 0, completed: 0, tasks: [] },
151
+ commit: { total: 0, completed: 0, tasks: [] },
152
+ docs: { total: 0, completed: 0, tasks: [] },
153
+ }
154
+
155
+ // project_id가 있으면 함께 조건 추가
156
+ const sql = projectId
157
+ ? `SELECT * FROM tasks WHERE change_id = ? AND project_id = ? AND status != 'archived' ORDER BY stage, group_order, sub_order, task_order, "order"`
158
+ : `SELECT * FROM tasks WHERE change_id = ? AND status != 'archived' ORDER BY stage, group_order, sub_order, task_order, "order"`
159
+ const params = projectId ? [changeId, projectId] : [changeId]
160
+
161
+ let tasks = sqlite.prepare(sql).all(...params) as Array<{
162
+ id: number
163
+ change_id: string
164
+ stage: Stage
165
+ title: string
166
+ description: string | null
167
+ status: string
168
+ priority: string
169
+ tags: string | null
170
+ assignee: string | null
171
+ order: number
172
+ group_title: string | null
173
+ group_order: number
174
+ task_order: number
175
+ major_title: string | null
176
+ sub_order: number | null
177
+ display_id: string | null
178
+ created_at: number
179
+ updated_at: number
180
+ archived_at: number | null
181
+ }>
182
+
183
+ // Fallback: project_id로 필터링했는데 결과가 없으면, project_id 없이 재시도 (레거시 데이터 지원)
184
+ if (tasks.length === 0 && projectId) {
185
+ const fallbackSql = `SELECT * FROM tasks WHERE change_id = ? AND status != 'archived' ORDER BY stage, group_order, sub_order, task_order, "order"`
186
+ tasks = sqlite.prepare(fallbackSql).all(changeId) as typeof tasks
187
+ }
188
+
189
+ for (const task of tasks) {
190
+ const stage = task.stage as Stage
191
+ stages[stage].total++
192
+ if (task.status === 'done') {
193
+ stages[stage].completed++
194
+ }
195
+ stages[stage].tasks.push({
196
+ id: task.id,
197
+ changeId: task.change_id,
198
+ stage: task.stage,
199
+ title: task.title,
200
+ description: task.description,
201
+ status: task.status,
202
+ priority: task.priority,
203
+ tags: task.tags ? JSON.parse(task.tags) : [],
204
+ assignee: task.assignee,
205
+ order: task.order,
206
+ groupTitle: task.group_title,
207
+ groupOrder: task.group_order,
208
+ taskOrder: task.task_order,
209
+ majorTitle: task.major_title,
210
+ subOrder: task.sub_order,
211
+ displayId: task.display_id,
212
+ createdAt: new Date(task.created_at).toISOString(),
213
+ updatedAt: new Date(task.updated_at).toISOString(),
214
+ archivedAt: task.archived_at ? new Date(task.archived_at).toISOString() : null,
215
+ })
216
+ }
217
+
218
+ return stages
219
+ }
220
+
221
+ // Helper: Calculate progress
222
+ function calculateProgress(stages: Record<Stage, { total: number; completed: number }>): number {
223
+ let totalTasks = 0
224
+ let completedTasks = 0
225
+ for (const stage of STAGES) {
226
+ totalTasks += stages[stage].total
227
+ completedTasks += stages[stage].completed
228
+ }
229
+ return totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
230
+ }
231
+
232
+ // Helper: Determine current stage
233
+ function determineCurrentStage(stages: Record<Stage, { total: number; completed: number }>): Stage {
234
+ for (const stage of STAGES) {
235
+ if (stages[stage].total > stages[stage].completed) {
236
+ return stage
237
+ }
238
+ }
239
+ return 'docs'
240
+ }
241
+
242
+ // Helper: Get stages for a remote change via SSH
243
+ async function getRemoteChangeStages(
244
+ changeId: string,
245
+ project: { path: string; remote: { serverId: string } }
246
+ ): Promise<Record<Stage, { total: number; completed: number; tasks: unknown[] }>> {
247
+ const stages: Record<Stage, { total: number; completed: number; tasks: unknown[] }> = {
248
+ spec: { total: 0, completed: 0, tasks: [] },
249
+ changes: { total: 0, completed: 0, tasks: [] },
250
+ task: { total: 0, completed: 0, tasks: [] },
251
+ code: { total: 0, completed: 0, tasks: [] },
252
+ test: { total: 0, completed: 0, tasks: [] },
253
+ commit: { total: 0, completed: 0, tasks: [] },
254
+ docs: { total: 0, completed: 0, tasks: [] },
255
+ }
256
+
257
+ try {
258
+ const plugin = await import('@zyflow/remote-plugin')
259
+ const server = await plugin.getRemoteServerById(project.remote.serverId)
260
+ if (!server) return stages
261
+
262
+ const tasksPath = `${project.path}/openspec/changes/${changeId}/tasks.md`
263
+ const tasksContent = await plugin.readRemoteFile(server, tasksPath)
264
+
265
+ const parsed = parseTasksFile(changeId, tasksContent)
266
+
267
+ // ExtendedTaskGroup 타입으로 캐스팅하여 확장 필드 접근
268
+ interface ExtendedGroup {
269
+ title: string
270
+ tasks: Array<{
271
+ id: string
272
+ title: string
273
+ completed: boolean
274
+ lineNumber: number
275
+ displayId?: string
276
+ }>
277
+ majorOrder?: number
278
+ majorTitle?: string
279
+ subOrder?: number
280
+ groupTitle?: string
281
+ groupOrder?: number
282
+ }
283
+
284
+ // tasks.md의 모든 태스크는 'task' stage에 매핑
285
+ for (const group of parsed.groups as ExtendedGroup[]) {
286
+ const majorOrder = group.majorOrder ?? 1
287
+ const majorTitle = group.majorTitle ?? group.title
288
+ const subOrder = group.subOrder ?? 1
289
+ const groupTitle = group.groupTitle ?? group.title
290
+ const groupOrder = group.groupOrder ?? majorOrder
291
+
292
+ for (let taskIdx = 0; taskIdx < group.tasks.length; taskIdx++) {
293
+ const task = group.tasks[taskIdx]
294
+ const taskOrder = taskIdx + 1
295
+
296
+ stages.task.total++
297
+ if (task.completed) {
298
+ stages.task.completed++
299
+ }
300
+ stages.task.tasks.push({
301
+ id: task.id,
302
+ changeId,
303
+ stage: 'task',
304
+ title: task.title,
305
+ description: null,
306
+ status: task.completed ? 'done' : 'todo',
307
+ priority: 'medium',
308
+ tags: [],
309
+ assignee: null,
310
+ order: taskOrder,
311
+ groupTitle,
312
+ groupOrder,
313
+ taskOrder,
314
+ majorTitle,
315
+ subOrder,
316
+ displayId: task.displayId || null,
317
+ createdAt: new Date().toISOString(),
318
+ updatedAt: new Date().toISOString(),
319
+ archivedAt: null,
320
+ })
321
+ }
322
+ }
323
+ } catch (error) {
324
+ // "No such file" is expected for archived changes or missing tasks.md - skip silently
325
+ const errorMessage = error instanceof Error ? error.message : String(error)
326
+ const errorCode = (error as { code?: number })?.code
327
+ if (errorCode !== 2 && !errorMessage.includes('No such file')) {
328
+ console.error('Failed to get remote change stages:', error)
329
+ }
330
+ }
331
+
332
+ return stages
333
+ }
334
+
335
+ // =============================================
336
+ // MoAI SPEC Support Functions (TAG-005)
337
+ // =============================================
338
+
339
+ // Helper: Check if ID is MoAI SPEC format (SPEC-XXX)
340
+ function isMoaiSpecId(id: string): boolean {
341
+ return id.startsWith('SPEC-')
342
+ }
343
+
344
+ // Helper: Calculate TAG progress from plan.md
345
+ async function calculateTagProgress(
346
+ specId: string,
347
+ projectPath: string
348
+ ): Promise<{ completed: number; total: number; percentage: number } | null> {
349
+ try {
350
+ const planPath = join(projectPath, '.moai', 'specs', specId, 'plan.md')
351
+ if (!existsSync(planPath)) {
352
+ return null
353
+ }
354
+
355
+ const content = await readFile(planPath, 'utf-8')
356
+ const parsed = parsePlanFile(content)
357
+
358
+ const completed = parsed.tags.filter((t) => t.completed).length
359
+ const total = parsed.tags.length
360
+ const percentage = total > 0 ? Math.round((completed / total) * 100) : 0
361
+
362
+ return { completed, total, percentage }
363
+ } catch {
364
+ return null
365
+ }
366
+ }
367
+
368
+ // Helper: Get MoAI SPEC detail with spec.md, plan.md, acceptance.md content
369
+ async function getMoaiSpecDetail(
370
+ specId: string,
371
+ projectId?: string
372
+ ): Promise<{
373
+ id: string
374
+ title: string
375
+ type: 'spec'
376
+ status: string
377
+ currentStage: Stage
378
+ progress: number
379
+ createdAt: string
380
+ updatedAt: string
381
+ spec: { content: string | null; title?: string } | null
382
+ plan: { content: string | null; tags: unknown[] | null; progress: { completed: number; total: number; percentage: number } | null } | null
383
+ acceptance: { content: string | null; criteria: unknown[] | null } | null
384
+ stages: Record<Stage, { total: number; completed: number; tasks: unknown[] }>
385
+ } | null> {
386
+ try {
387
+ const sqlite = getSqlite()
388
+
389
+ // Get change record from DB
390
+ let change
391
+ if (projectId) {
392
+ change = sqlite
393
+ .prepare(`SELECT * FROM changes WHERE id = ? AND project_id = ?`)
394
+ .get(specId, projectId) as {
395
+ id: string
396
+ project_id: string
397
+ title: string
398
+ spec_path: string | null
399
+ status: ChangeStatus
400
+ current_stage: Stage
401
+ progress: number
402
+ created_at: number
403
+ updated_at: number
404
+ } | undefined
405
+ } else {
406
+ change = sqlite
407
+ .prepare(`SELECT * FROM changes WHERE id = ?`)
408
+ .get(specId) as typeof change
409
+ }
410
+
411
+ // Get project path from config
412
+ const config = await loadConfig()
413
+ let project
414
+ let projectPath: string
415
+
416
+ if (change) {
417
+ // DB record exists - use its project_id
418
+ project = config.projects.find((p) => p.id === change.project_id)
419
+ if (!project) {
420
+ return null
421
+ }
422
+ projectPath = project.path
423
+
424
+ // For remote projects, read spec.md to get current status (DB may be stale)
425
+ if (project.remote) {
426
+ const plugin = await getRemotePlugin()
427
+ if (plugin) {
428
+ const server = await plugin.getRemoteServerById(project.remote.serverId)
429
+ if (server) {
430
+ const remoteSpecPath = `${projectPath}/.moai/specs/${specId}/spec.md`
431
+ try {
432
+ const specContent = await plugin.readRemoteFile(server, remoteSpecPath)
433
+ // Extract status from frontmatter
434
+ const statusMatch = specContent.match(/^status:\s+(.+)$/m)
435
+ if (statusMatch) {
436
+ const specStatus = statusMatch[1].trim().replace(/^["']|["']$/g, '').toLowerCase()
437
+ const isComplete = specStatus === 'complete' || specStatus === 'completed' || specStatus === 'implemented'
438
+ const mappedStatus = (isComplete ? 'done' : specStatus === 'active' ? 'in_progress' : 'pending') as ChangeStatus
439
+ const mappedStage = (isComplete ? 'sync' : specStatus === 'active' ? 'run' : 'plan') as Stage
440
+
441
+ // Count tags for progress
442
+ const tagMatches = specContent.match(/\bTAG-[A-Z]+-\d+\b/g) || []
443
+ const tagCount = tagMatches.length
444
+ const completedTagMatches = specContent.match(/\[x\]\s*\*\*TAG-[A-Z]+-\d+\*\*/gi) || []
445
+ const completedTags = completedTagMatches.length
446
+ const progress = isComplete ? 100 : (tagCount > 0 ? Math.round((completedTags / tagCount) * 100) : 0)
447
+
448
+ // Update change with current status from spec.md
449
+ change = {
450
+ ...change,
451
+ status: mappedStatus,
452
+ current_stage: mappedStage,
453
+ progress,
454
+ }
455
+ }
456
+ } catch {
457
+ // Failed to read spec.md, continue with DB values
458
+ }
459
+ }
460
+ }
461
+ }
462
+ } else {
463
+ // DB record doesn't exist - try to find MoAI SPEC in filesystem
464
+ // First try with projectId parameter, then fall back to active project
465
+ project = projectId
466
+ ? config.projects.find((p) => p.id === projectId)
467
+ : config.projects.find((p) => p.is_active)
468
+
469
+ if (!project) {
470
+ return null
471
+ }
472
+ projectPath = project.path
473
+
474
+ // For remote projects, check via SSH
475
+ if (project.remote) {
476
+ const plugin = await getRemotePlugin()
477
+ if (!plugin) {
478
+ return null
479
+ }
480
+ const server = await plugin.getRemoteServerById(project.remote.serverId)
481
+ if (!server) {
482
+ return null
483
+ }
484
+
485
+ // Try to read spec.md to verify SPEC exists on remote
486
+ const remoteSpecPath = `${projectPath}/.moai/specs/${specId}/spec.md`
487
+ try {
488
+ const specContent = await plugin.readRemoteFile(server, remoteSpecPath)
489
+ // Extract title from frontmatter
490
+ let title = specId
491
+ const titleMatch = specContent.match(/^title:\s+(.+)$/m)
492
+ if (titleMatch) {
493
+ title = titleMatch[1].trim().replace(/^["']|["']$/g, '')
494
+ }
495
+
496
+ // Extract status from frontmatter
497
+ let specStatus = 'draft'
498
+ const statusMatch = specContent.match(/^status:\s+(.+)$/m)
499
+ if (statusMatch) {
500
+ specStatus = statusMatch[1].trim().replace(/^["']|["']$/g, '').toLowerCase()
501
+ }
502
+ // Count tags from content (TAG-XXX-NNN pattern)
503
+ const tagMatches = specContent.match(/\bTAG-[A-Z]+-\d+\b/g) || []
504
+ const tagCount = tagMatches.length
505
+ // Count completed tags (marked with [x])
506
+ const completedTagMatches = specContent.match(/\[x\]\s*\*\*TAG-[A-Z]+-\d+\*\*/gi) || []
507
+ const completedTags = completedTagMatches.length
508
+
509
+ // Map status to ChangeStatus (same logic as local SPEC)
510
+ // 'implemented', 'complete', and 'completed' are all considered done
511
+ const isComplete = specStatus === 'complete' || specStatus === 'completed' || specStatus === 'implemented'
512
+ const mappedStatus = (isComplete ? 'done' : specStatus === 'active' ? 'in_progress' : 'pending') as ChangeStatus
513
+ const mappedStage = (isComplete ? 'sync' : specStatus === 'active' ? 'run' : 'plan') as Stage
514
+ // If marked complete but no TAGs found in spec.md, use 100% progress
515
+ const progress = isComplete ? 100 : (tagCount > 0 ? Math.round((completedTags / tagCount) * 100) : 0)
516
+
517
+ // Create a virtual change record for remote SPEC
518
+ change = {
519
+ id: specId,
520
+ project_id: project.id,
521
+ title,
522
+ spec_path: remoteSpecPath,
523
+ status: mappedStatus,
524
+ current_stage: mappedStage,
525
+ progress,
526
+ created_at: Date.now(),
527
+ updated_at: Date.now(),
528
+ }
529
+ } catch {
530
+ // SPEC doesn't exist on remote
531
+ return null
532
+ }
533
+ } else {
534
+ // Check if MoAI SPEC exists in local filesystem
535
+ const moaiSpec = await getMoaiSpec(projectPath, specId)
536
+ if (!moaiSpec) {
537
+ return null
538
+ }
539
+
540
+ // Create a virtual change record from filesystem data
541
+ change = {
542
+ id: specId,
543
+ project_id: project.id,
544
+ title: moaiSpec.title,
545
+ spec_path: moaiSpec.path,
546
+ status: (moaiSpec.status === 'complete' ? 'done' : moaiSpec.status === 'active' ? 'in_progress' : 'pending') as ChangeStatus,
547
+ current_stage: (moaiSpec.status === 'complete' ? 'sync' : moaiSpec.status === 'active' ? 'run' : 'plan') as Stage,
548
+ progress: moaiSpec.tagCount > 0 ? Math.round((moaiSpec.completedTags / moaiSpec.tagCount) * 100) : 0,
549
+ created_at: Date.now(),
550
+ updated_at: Date.now(),
551
+ }
552
+ }
553
+ }
554
+
555
+ const specsDir = join(projectPath, '.moai', 'specs', specId)
556
+
557
+ // Check if project is remote
558
+ const isRemote = !!project.remote
559
+ let plugin: RemotePlugin | null = null
560
+ let server: unknown = null
561
+
562
+ if (isRemote) {
563
+ plugin = await getRemotePlugin()
564
+ if (plugin) {
565
+ server = await plugin.getRemoteServerById(project.remote!.serverId)
566
+ }
567
+ }
568
+
569
+ // Read spec.md
570
+ let specContent: string | null = null
571
+ let specTitle = change.title
572
+ const specPath = isRemote ? `${specsDir}/spec.md` : join(specsDir, 'spec.md')
573
+
574
+ if (isRemote && plugin && server) {
575
+ try {
576
+ specContent = await plugin.readRemoteFile(server, specPath)
577
+ // Try to extract title from spec.md frontmatter
578
+ const titleMatch = specContent.match(/^title:\s+(.+)$/m)
579
+ if (titleMatch) {
580
+ specTitle = titleMatch[1].trim().replace(/^["']|["']$/g, '')
581
+ }
582
+ } catch {
583
+ // spec.md not readable on remote
584
+ }
585
+ } else if (!isRemote && existsSync(specPath)) {
586
+ try {
587
+ specContent = await readFile(specPath, 'utf-8')
588
+ // Try to extract title from spec.md frontmatter
589
+ const titleMatch = specContent.match(/^title:\s+(.+)$/m)
590
+ if (titleMatch) {
591
+ specTitle = titleMatch[1].trim().replace(/^["']|["']$/g, '')
592
+ }
593
+ } catch {
594
+ // spec.md not readable
595
+ }
596
+ }
597
+
598
+ // Read plan.md and parse TAGs
599
+ let planContent: string | null = null
600
+ let tags = null
601
+ let tagProgress = null
602
+ const planPath = isRemote ? `${specsDir}/plan.md` : join(specsDir, 'plan.md')
603
+
604
+ if (isRemote && plugin && server) {
605
+ try {
606
+ planContent = await plugin.readRemoteFile(server, planPath)
607
+ const parsed = parsePlanFile(planContent)
608
+ tags = parsed.tags
609
+ // Calculate tag progress from parsed content
610
+ if (parsed.tags.length > 0) {
611
+ const completed = parsed.tags.filter((t) => t.completed).length
612
+ const total = parsed.tags.length
613
+ tagProgress = { completed, total, percentage: Math.round((completed / total) * 100) }
614
+ }
615
+ // Note: If no TAGs, tagProgress will be calculated from acceptance.md below
616
+ } catch {
617
+ // plan.md not readable on remote
618
+ }
619
+ } else if (!isRemote && existsSync(planPath)) {
620
+ try {
621
+ planContent = await readFile(planPath, 'utf-8')
622
+ const parsed = parsePlanFile(planContent)
623
+ tags = parsed.tags
624
+ // Calculate tag progress from parsed content (same as remote path)
625
+ if (parsed.tags.length > 0) {
626
+ const completed = parsed.tags.filter((t) => t.completed).length
627
+ const total = parsed.tags.length
628
+ tagProgress = { completed, total, percentage: Math.round((completed / total) * 100) }
629
+ }
630
+ // Note: If no TAGs, tagProgress will be calculated from acceptance.md below
631
+ } catch {
632
+ // plan.md not readable or parse error
633
+ }
634
+ }
635
+
636
+ // Read acceptance.md and parse criteria
637
+ let acceptanceContent: string | null = null
638
+ let criteria = null
639
+ const acceptancePath = isRemote ? `${specsDir}/acceptance.md` : join(specsDir, 'acceptance.md')
640
+
641
+ if (isRemote && plugin && server) {
642
+ try {
643
+ acceptanceContent = await plugin.readRemoteFile(server, acceptancePath)
644
+ const parsed = parseAcceptanceFile(acceptanceContent)
645
+ criteria = parsed.criteria
646
+ // Fallback: use acceptance.md checklists for progress if no TAGs found
647
+ if (!tagProgress && acceptanceContent) {
648
+ tagProgress = countMarkdownChecklists(acceptanceContent)
649
+ }
650
+ } catch {
651
+ // acceptance.md not readable on remote
652
+ }
653
+ } else if (!isRemote && existsSync(acceptancePath)) {
654
+ try {
655
+ acceptanceContent = await readFile(acceptancePath, 'utf-8')
656
+ const parsed = parseAcceptanceFile(acceptanceContent)
657
+ criteria = parsed.criteria
658
+ // Fallback: use acceptance.md checklists for progress if no TAGs found
659
+ if (!tagProgress && acceptanceContent) {
660
+ tagProgress = countMarkdownChecklists(acceptanceContent)
661
+ }
662
+ } catch {
663
+ // acceptance.md not readable or parse error
664
+ }
665
+ }
666
+
667
+ // Check if SPEC is marked as complete/implemented in frontmatter
668
+ let isStatusComplete = false
669
+ if (specContent) {
670
+ const statusMatch = specContent.match(/^status:\s*(\w+)/mi)
671
+ if (statusMatch) {
672
+ const specStatus = statusMatch[1].toLowerCase()
673
+ isStatusComplete = specStatus === 'complete' || specStatus === 'completed' || specStatus === 'implemented'
674
+ }
675
+ }
676
+
677
+ // If status is complete but no tagProgress found, show 100%
678
+ if (isStatusComplete && (!tagProgress || tagProgress.percentage === 0)) {
679
+ tagProgress = { completed: 1, total: 1, percentage: 100 }
680
+ // Also update change.progress for main progress display
681
+ change.progress = 100
682
+ }
683
+
684
+ // Get stages for compatibility with OpenSpec format
685
+ let stages = getChangeStages(specId, change.project_id)
686
+
687
+ // For MoAI SPECs: Map TAGs to 'plan' stage if no tasks in DB
688
+ if (tags && tags.length > 0) {
689
+ const totalStagesTasks = Object.values(stages).reduce((sum, s) => sum + s.total, 0)
690
+
691
+ // If no tasks in DB, populate stages from TAGs
692
+ if (totalStagesTasks === 0) {
693
+ stages = {
694
+ spec: { total: 0, completed: 0, tasks: [] },
695
+ changes: { total: 0, completed: 0, tasks: [] },
696
+ task: { total: 0, completed: 0, tasks: [] },
697
+ code: { total: 0, completed: 0, tasks: [] },
698
+ test: { total: 0, completed: 0, tasks: [] },
699
+ commit: { total: 0, completed: 0, tasks: [] },
700
+ docs: { total: 0, completed: 0, tasks: [] },
701
+ }
702
+
703
+ // Map TAGs to 'plan' stage (using 'task' for UI compatibility)
704
+ for (const tag of tags) {
705
+ stages.task.total++
706
+ if (tag.completed) {
707
+ stages.task.completed++
708
+ }
709
+ stages.task.tasks.push({
710
+ id: tag.id,
711
+ changeId: specId,
712
+ stage: 'task',
713
+ title: tag.title,
714
+ description: tag.description || null,
715
+ status: tag.completed ? 'done' : 'todo',
716
+ priority: 'medium',
717
+ tags: [],
718
+ assignee: null,
719
+ order: parseInt(tag.id.replace('TAG-', ''), 10) || 0,
720
+ groupTitle: null,
721
+ groupOrder: 0,
722
+ taskOrder: parseInt(tag.id.replace('TAG-', ''), 10) || 0,
723
+ majorTitle: null,
724
+ subOrder: 0,
725
+ displayId: tag.id,
726
+ createdAt: new Date().toISOString(),
727
+ updatedAt: new Date().toISOString(),
728
+ archivedAt: null,
729
+ })
730
+ }
731
+ }
732
+ }
733
+
734
+ // Map DB status to frontend format
735
+ const frontendStatusMap: Record<string, string> = {
736
+ 'done': 'completed',
737
+ 'in_progress': 'active',
738
+ 'pending': 'draft',
739
+ 'active': 'active',
740
+ 'completed': 'completed',
741
+ 'archived': 'archived',
742
+ }
743
+ const frontendStatus = frontendStatusMap[change.status] || change.status
744
+
745
+ return {
746
+ id: change.id,
747
+ title: specTitle,
748
+ type: 'spec',
749
+ status: frontendStatus,
750
+ currentStage: change.current_stage,
751
+ progress: change.progress,
752
+ createdAt: new Date(change.created_at).toISOString(),
753
+ updatedAt: new Date(change.updated_at).toISOString(),
754
+ spec: specContent ? { content: specContent, title: specTitle } : null,
755
+ plan: planContent ? { content: planContent, tags, progress: tagProgress } : null,
756
+ acceptance: acceptanceContent ? { content: acceptanceContent, criteria } : null,
757
+ stages,
758
+ }
759
+ } catch (error) {
760
+ console.error('Error getting MoAI SPEC detail:', error)
761
+ return null
762
+ }
763
+ }
764
+
765
+ // GET /changes/counts - 프로젝트별 Change 수 (상태별 집계)
766
+ flowRouter.get('/changes/counts', async (req, res) => {
767
+ try {
768
+ await initTaskDb()
769
+ const config = await loadConfig()
770
+ const sqlite = getSqlite()
771
+
772
+ const { status } = req.query
773
+
774
+ const counts: Record<string, number> = {}
775
+ const detailedCounts: Record<string, { active: number; completed: number; total: number }> = {}
776
+
777
+ const projectIds = config.projects.map((p) => p.id)
778
+ const placeholders = projectIds.map(() => '?').join(',')
779
+
780
+ const detailedResults = sqlite
781
+ .prepare(
782
+ `
783
+ SELECT
784
+ project_id,
785
+ SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active,
786
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
787
+ COUNT(*) as total
788
+ FROM changes
789
+ WHERE project_id IN (${placeholders})
790
+ GROUP BY project_id
791
+ `
792
+ )
793
+ .all(...projectIds) as Array<{
794
+ project_id: string
795
+ active: number
796
+ completed: number
797
+ total: number
798
+ }>
799
+
800
+ for (const project of config.projects) {
801
+ const projectResult = detailedResults.find((r) => r.project_id === project.id)
802
+
803
+ let count = 0
804
+ if (status === 'active') {
805
+ count = projectResult?.active ?? 0
806
+ } else if (status === 'completed') {
807
+ count = projectResult?.completed ?? 0
808
+ } else {
809
+ count = projectResult?.total ?? 0
810
+ }
811
+ counts[project.id] = count
812
+
813
+ detailedCounts[project.id] = {
814
+ active: projectResult?.active ?? 0,
815
+ completed: projectResult?.completed ?? 0,
816
+ total: projectResult?.total ?? 0,
817
+ }
818
+ }
819
+
820
+ const responseData = { counts, detailed: detailedCounts }
821
+
822
+ res.json({ success: true, data: responseData })
823
+ } catch (error) {
824
+ console.error('Error getting change counts:', error)
825
+ res.status(500).json({
826
+ success: false,
827
+ error: error instanceof Error ? error.message : 'Unknown error',
828
+ })
829
+ }
830
+ })
831
+
832
+ // GET /changes - Flow Changes 목록 (모든 프로젝트)
833
+ flowRouter.get('/changes', async (_req, res) => {
834
+ try {
835
+ await initTaskDb()
836
+ const config = await loadConfig()
837
+
838
+ if (config.projects.length === 0) {
839
+ return res.json({ success: true, data: { changes: [] } })
840
+ }
841
+
842
+ const sqlite = getSqlite()
843
+ const allChanges: Array<{
844
+ id: string
845
+ type: string
846
+ projectId: string
847
+ projectName: string
848
+ title: string
849
+ specPath: string | null
850
+ status: string
851
+ currentStage: string
852
+ progress: number
853
+ createdAt: string
854
+ updatedAt: string
855
+ stages: ReturnType<typeof getChangeStages>
856
+ }> = []
857
+
858
+ // Scan all projects
859
+ for (const project of config.projects) {
860
+ // Get OpenSpec changes from DB
861
+ const dbChanges = sqlite
862
+ .prepare(
863
+ `
864
+ SELECT * FROM changes
865
+ WHERE project_id = ? AND status != 'archived'
866
+ ORDER BY updated_at DESC
867
+ `
868
+ )
869
+ .all(project.id) as Array<{
870
+ id: string
871
+ project_id: string
872
+ title: string
873
+ spec_path: string | null
874
+ status: ChangeStatus
875
+ current_stage: Stage
876
+ progress: number
877
+ created_at: number
878
+ updated_at: number
879
+ }>
880
+
881
+ for (const c of dbChanges) {
882
+ const stages = getChangeStages(c.id, project.id)
883
+ const progress = calculateProgress(stages)
884
+ const currentStage = determineCurrentStage(stages)
885
+ const type = c.spec_path?.startsWith('.moai/specs/') ? 'moai-spec' : 'openspec'
886
+
887
+ allChanges.push({
888
+ id: c.id,
889
+ type,
890
+ projectId: c.project_id,
891
+ projectName: project.name,
892
+ title: c.title,
893
+ specPath: c.spec_path,
894
+ status: c.status,
895
+ currentStage,
896
+ progress,
897
+ createdAt: new Date(c.created_at).toISOString(),
898
+ updatedAt: new Date(c.updated_at).toISOString(),
899
+ stages,
900
+ })
901
+ }
902
+
903
+ // Scan MoAI SPECs from .moai/specs/ directory (local or remote)
904
+ try {
905
+ let moaiSpecs: Awaited<ReturnType<typeof scanMoaiSpecs>> = []
906
+
907
+ if (project.remote) {
908
+ // Remote SSH project
909
+ const plugin = await getRemotePlugin()
910
+ if (plugin) {
911
+ const server = await plugin.getRemoteServerById(project.remote.serverId)
912
+ if (server) {
913
+ moaiSpecs = await scanRemoteMoaiSpecs(project.path, server, plugin)
914
+ }
915
+ }
916
+ } else {
917
+ // Local project
918
+ moaiSpecs = await scanMoaiSpecs(project.path)
919
+ }
920
+
921
+ for (const spec of moaiSpecs) {
922
+ if (spec.status === 'archived') continue
923
+
924
+ // Calculate fresh progress from scanned data
925
+ const freshProgress = spec.tagCount > 0 ? Math.round((spec.completedTags / spec.tagCount) * 100) : 0
926
+
927
+ // Check if already in DB - if so, UPDATE with fresh progress
928
+ const existingIdx = allChanges.findIndex((c) => c.id === spec.id && c.projectId === project.id)
929
+ if (existingIdx !== -1) {
930
+ // Update existing entry with fresh scanned progress
931
+ allChanges[existingIdx].progress = freshProgress
932
+ allChanges[existingIdx].totalTasks = spec.tagCount
933
+ allChanges[existingIdx].completedTasks = spec.completedTags
934
+ continue
935
+ }
936
+
937
+ // Map MoAI SPEC status to changes table status for consistency
938
+ // MoAI: draft, active, complete, archived
939
+ // Changes table: active, completed, archived
940
+ const mapSpecStatusToDbStatus = (specStatus: string): ChangeStatus => {
941
+ switch (specStatus) {
942
+ case 'complete':
943
+ case 'completed':
944
+ return 'completed'
945
+ case 'archived':
946
+ return 'archived'
947
+ default:
948
+ return 'active' // draft, active -> active
949
+ }
950
+ }
951
+
952
+ allChanges.push({
953
+ id: spec.id,
954
+ type: 'moai-spec',
955
+ projectId: project.id,
956
+ projectName: project.name,
957
+ title: spec.title,
958
+ specPath: `.moai/specs/${spec.id}`,
959
+ status: mapSpecStatusToDbStatus(spec.status),
960
+ currentStage: spec.status === 'complete' ? 'docs' : spec.status === 'active' ? 'code' : 'spec',
961
+ progress: spec.tagCount > 0 ? Math.round((spec.completedTags / spec.tagCount) * 100) : 0,
962
+ createdAt: spec.createdAt || new Date().toISOString(),
963
+ updatedAt: spec.updatedAt || new Date().toISOString(),
964
+ stages: getChangeStages(spec.id, project.id),
965
+ })
966
+ }
967
+ } catch (err) {
968
+ console.warn(`Failed to scan MoAI SPECs for ${project.name}:`, err)
969
+ }
970
+ }
971
+
972
+ // Sort by updatedAt (most recent first)
973
+ const changes = allChanges.sort((a, b) => {
974
+ const aTime = new Date(a.updatedAt).getTime()
975
+ const bTime = new Date(b.updatedAt).getTime()
976
+ return bTime - aTime
977
+ })
978
+
979
+ res.json({ success: true, data: { changes } })
980
+ } catch (error) {
981
+ console.error('Error listing flow changes:', error)
982
+ res.status(500).json({ success: false, error: 'Failed to list flow changes' })
983
+ }
984
+ })
985
+
986
+ // GET /changes/:id - Flow Change 상세 (stages 포함, MoAI SPEC 지원)
987
+ flowRouter.get('/changes/:id', async (req, res) => {
988
+ try {
989
+ await initTaskDb()
990
+ const config = await loadConfig()
991
+ // URL 쿼리에서 projectId 받기, 없으면 activeProjectId 사용
992
+ const queryProjectId = req.query.projectId as string | undefined
993
+ const targetProjectId = queryProjectId || config.activeProjectId
994
+
995
+ // Check if this is a MoAI SPEC ID (SPEC-XXX format)
996
+ if (isMoaiSpecId(req.params.id)) {
997
+ const specDetail = await getMoaiSpecDetail(req.params.id, targetProjectId)
998
+ if (specDetail) {
999
+ return res.json({
1000
+ success: true,
1001
+ data: {
1002
+ change: specDetail,
1003
+ },
1004
+ })
1005
+ }
1006
+ }
1007
+
1008
+ const sqlite = getSqlite()
1009
+
1010
+ // 지정된 프로젝트에서 우선 조회 (같은 changeId가 여러 프로젝트에 있을 수 있음)
1011
+ let change = targetProjectId
1012
+ ? (sqlite
1013
+ .prepare(`SELECT * FROM changes WHERE id = ? AND project_id = ?`)
1014
+ .get(req.params.id, targetProjectId) as {
1015
+ id: string
1016
+ project_id: string
1017
+ title: string
1018
+ spec_path: string | null
1019
+ status: ChangeStatus
1020
+ current_stage: Stage
1021
+ progress: number
1022
+ created_at: number
1023
+ updated_at: number
1024
+ } | undefined)
1025
+ : undefined
1026
+
1027
+ // 활성 프로젝트에 없으면 전체에서 조회 (fallback)
1028
+ if (!change) {
1029
+ change = sqlite
1030
+ .prepare(`SELECT * FROM changes WHERE id = ?`)
1031
+ .get(req.params.id) as typeof change
1032
+ }
1033
+
1034
+ if (!change) {
1035
+ return res.status(404).json({ success: false, error: 'Change not found' })
1036
+ }
1037
+
1038
+ const project = config.projects.find((p) => p.id === change.project_id)
1039
+ if (!project) {
1040
+ return res.status(404).json({ success: false, error: 'Project not found for this change' })
1041
+ }
1042
+
1043
+ // 원격 프로젝트인 경우 SSH로 tasks.md 읽어서 stages 계산
1044
+ // 로컬 프로젝트인 경우 DB에서 조회
1045
+ const stages = project.remote
1046
+ ? await getRemoteChangeStages(change.id, project as { path: string; remote: { serverId: string } })
1047
+ : getChangeStages(change.id, project.id)
1048
+ const progress = calculateProgress(stages)
1049
+ const currentStage = determineCurrentStage(stages)
1050
+
1051
+ let gitCreatedAt: string | null = null
1052
+ let gitUpdatedAt: string | null = null
1053
+ // 원격 프로젝트가 아닌 경우에만 git 명령 실행
1054
+ if (!project.remote) {
1055
+ try {
1056
+ const relativeChangeDir = `openspec/changes/${change.id}`
1057
+ const { stdout: updatedStdout } = await execAsync(
1058
+ `git log -1 --format="%aI" -- "${relativeChangeDir}"`,
1059
+ { cwd: project.path }
1060
+ )
1061
+ if (updatedStdout.trim()) {
1062
+ gitUpdatedAt = updatedStdout.trim()
1063
+ }
1064
+ const { stdout: createdStdout } = await execAsync(
1065
+ `git log --diff-filter=A --format="%aI" -- "${relativeChangeDir}" | tail -1`,
1066
+ { cwd: project.path }
1067
+ )
1068
+ if (createdStdout.trim()) {
1069
+ gitCreatedAt = createdStdout.trim()
1070
+ }
1071
+ } catch {
1072
+ // Git 명령 실패 시 DB 값 사용
1073
+ }
1074
+ }
1075
+
1076
+ res.json({
1077
+ success: true,
1078
+ data: {
1079
+ change: {
1080
+ id: change.id,
1081
+ projectId: change.project_id,
1082
+ title: change.title,
1083
+ specPath: change.spec_path,
1084
+ status: change.status,
1085
+ currentStage,
1086
+ progress,
1087
+ createdAt: gitCreatedAt || new Date(change.created_at).toISOString(),
1088
+ updatedAt: gitUpdatedAt || new Date(change.updated_at).toISOString(),
1089
+ },
1090
+ stages,
1091
+ },
1092
+ })
1093
+ } catch (error) {
1094
+ console.error('Error getting flow change:', error)
1095
+ res.status(500).json({ success: false, error: 'Failed to get flow change' })
1096
+ }
1097
+ })
1098
+
1099
+ // POST /sync - OpenSpec에서 Changes 동기화 (모든 프로젝트)
1100
+ flowRouter.post('/sync', async (_req, res) => {
1101
+ try {
1102
+ await initTaskDb()
1103
+ const config = await loadConfig()
1104
+
1105
+ if (!config.projects.length) {
1106
+ return res.json({ success: true, data: { synced: 0, created: 0, updated: 0, projects: 0 } })
1107
+ }
1108
+
1109
+ const sqlite = getSqlite()
1110
+ let totalCreated = 0
1111
+ let totalUpdated = 0
1112
+ let projectsSynced = 0
1113
+
1114
+ for (const project of config.projects) {
1115
+ const openspecDir = join(project.path, 'openspec', 'changes')
1116
+ let entries
1117
+ try {
1118
+ entries = await readdir(openspecDir, { withFileTypes: true })
1119
+ } catch {
1120
+ continue
1121
+ }
1122
+
1123
+ projectsSynced++
1124
+
1125
+ for (const entry of entries) {
1126
+ if (!entry.isDirectory() || entry.name === 'archive') continue
1127
+
1128
+ const changeId = entry.name
1129
+ const changeDir = join(openspecDir, changeId)
1130
+
1131
+ let title = changeId
1132
+ const specPath = `openspec/changes/${changeId}/proposal.md`
1133
+ try {
1134
+ const proposalPath = join(changeDir, 'proposal.md')
1135
+ const proposalContent = await readFile(proposalPath, 'utf-8')
1136
+ const titleMatch = proposalContent.match(/^#\s+(?:Change:\s+)?(.+)$/m)
1137
+ if (titleMatch) {
1138
+ title = titleMatch[1].trim()
1139
+ }
1140
+ } catch {
1141
+ // proposal.md not found
1142
+ }
1143
+
1144
+ const existing = sqlite
1145
+ .prepare('SELECT id FROM changes WHERE id = ? AND project_id = ?')
1146
+ .get(changeId, project.id)
1147
+ const now = Date.now()
1148
+
1149
+ if (existing) {
1150
+ sqlite
1151
+ .prepare(
1152
+ `
1153
+ UPDATE changes SET title = ?, spec_path = ?, updated_at = ? WHERE id = ? AND project_id = ?
1154
+ `
1155
+ )
1156
+ .run(title, specPath, now, changeId, project.id)
1157
+ totalUpdated++
1158
+ } else {
1159
+ sqlite
1160
+ .prepare(
1161
+ `
1162
+ INSERT INTO changes (id, project_id, title, spec_path, status, current_stage, progress, created_at, updated_at)
1163
+ VALUES (?, ?, ?, ?, 'active', 'spec', 0, ?, ?)
1164
+ `
1165
+ )
1166
+ .run(changeId, project.id, title, specPath, now, now)
1167
+ totalCreated++
1168
+ }
1169
+
1170
+ // Sync tasks from tasks.md
1171
+ try {
1172
+ const tasksPath = join(changeDir, 'tasks.md')
1173
+ const tasksContent = await readFile(tasksPath, 'utf-8')
1174
+ const parsed = parseTasksFile(changeId, tasksContent)
1175
+
1176
+ interface ExtendedGroup {
1177
+ title: string
1178
+ tasks: Array<{
1179
+ title: string
1180
+ completed: boolean
1181
+ lineNumber: number
1182
+ displayId?: string
1183
+ }>
1184
+ majorOrder?: number
1185
+ majorTitle?: string
1186
+ subOrder?: number
1187
+ groupTitle?: string
1188
+ }
1189
+
1190
+ const parsedDisplayIds = new Set<string>()
1191
+
1192
+ for (const group of parsed.groups as ExtendedGroup[]) {
1193
+ const majorOrder = group.majorOrder ?? 1
1194
+ const majorTitle = group.majorTitle ?? group.title
1195
+ const subOrder = group.subOrder ?? 1
1196
+ const groupTitle = group.groupTitle ?? group.title
1197
+
1198
+ for (let taskIdx = 0; taskIdx < group.tasks.length; taskIdx++) {
1199
+ const task = group.tasks[taskIdx]
1200
+ const taskOrder = taskIdx + 1
1201
+ const displayId = task.displayId || null
1202
+
1203
+ if (displayId) {
1204
+ parsedDisplayIds.add(displayId)
1205
+ }
1206
+
1207
+ let existingTask: { id: number } | undefined
1208
+
1209
+ if (displayId) {
1210
+ existingTask = sqlite
1211
+ .prepare(
1212
+ `
1213
+ SELECT id FROM tasks WHERE change_id = ? AND display_id = ?
1214
+ `
1215
+ )
1216
+ .get(changeId, displayId) as { id: number } | undefined
1217
+ }
1218
+
1219
+ if (!existingTask) {
1220
+ existingTask = sqlite
1221
+ .prepare(
1222
+ `
1223
+ SELECT id FROM tasks WHERE change_id = ? AND title = ?
1224
+ `
1225
+ )
1226
+ .get(changeId, task.title) as { id: number } | undefined
1227
+ }
1228
+
1229
+ if (existingTask) {
1230
+ const newStatus = task.completed ? 'done' : 'todo'
1231
+ sqlite
1232
+ .prepare(
1233
+ `
1234
+ UPDATE tasks
1235
+ SET title = ?,
1236
+ status = ?,
1237
+ group_title = ?,
1238
+ group_order = ?,
1239
+ task_order = ?,
1240
+ major_title = ?,
1241
+ sub_order = ?,
1242
+ display_id = ?,
1243
+ project_id = ?,
1244
+ updated_at = ?
1245
+ WHERE id = ?
1246
+ `
1247
+ )
1248
+ .run(
1249
+ task.title,
1250
+ newStatus,
1251
+ groupTitle,
1252
+ majorOrder,
1253
+ taskOrder,
1254
+ majorTitle,
1255
+ subOrder,
1256
+ displayId,
1257
+ project.id,
1258
+ now,
1259
+ existingTask.id
1260
+ )
1261
+ } else {
1262
+ sqlite
1263
+ .prepare(`UPDATE sequences SET value = value + 1 WHERE name = 'task_openspec'`)
1264
+ .run()
1265
+ const seqResult = sqlite
1266
+ .prepare(`SELECT value FROM sequences WHERE name = 'task_openspec'`)
1267
+ .get() as { value: number }
1268
+ const newId = seqResult.value
1269
+
1270
+ sqlite
1271
+ .prepare(
1272
+ `
1273
+ INSERT INTO tasks (
1274
+ id, project_id, change_id, stage, title, status, priority, "order",
1275
+ group_title, group_order, task_order, major_title, sub_order,
1276
+ display_id, origin, created_at, updated_at
1277
+ )
1278
+ VALUES (?, ?, ?, 'task', ?, ?, 'medium', ?, ?, ?, ?, ?, ?, ?, 'openspec', ?, ?)
1279
+ `
1280
+ )
1281
+ .run(
1282
+ newId,
1283
+ project.id,
1284
+ changeId,
1285
+ task.title,
1286
+ task.completed ? 'done' : 'todo',
1287
+ task.lineNumber,
1288
+ groupTitle,
1289
+ majorOrder,
1290
+ taskOrder,
1291
+ majorTitle,
1292
+ subOrder,
1293
+ displayId,
1294
+ now,
1295
+ now
1296
+ )
1297
+ }
1298
+ }
1299
+ }
1300
+
1301
+ if (parsedDisplayIds.size > 0) {
1302
+ const dbTasks = sqlite
1303
+ .prepare(
1304
+ `
1305
+ SELECT id, display_id FROM tasks
1306
+ WHERE change_id = ? AND display_id IS NOT NULL AND status != 'archived'
1307
+ `
1308
+ )
1309
+ .all(changeId) as Array<{ id: number; display_id: string }>
1310
+
1311
+ for (const dbTask of dbTasks) {
1312
+ if (!parsedDisplayIds.has(dbTask.display_id)) {
1313
+ sqlite
1314
+ .prepare(
1315
+ `
1316
+ UPDATE tasks SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?
1317
+ `
1318
+ )
1319
+ .run(now, now, dbTask.id)
1320
+ }
1321
+ }
1322
+ }
1323
+ } catch {
1324
+ // tasks.md not found or parse error
1325
+ }
1326
+ }
1327
+ }
1328
+
1329
+ // Sync MoAI SPECs (.moai/specs/)
1330
+ for (const project of config.projects) {
1331
+ try {
1332
+ const moaiResult = await scanMoaiSpecs(project.path, project.id)
1333
+ totalCreated += moaiResult.totalCreated
1334
+ totalUpdated += moaiResult.totalUpdated
1335
+ if (moaiResult.specsProcessed > 0) {
1336
+ console.log(`[Sync] MoAI SPECs synced for ${project.id}: ${moaiResult.specsProcessed} specs processed`)
1337
+ }
1338
+ } catch (error) {
1339
+ console.error(`Error syncing MoAI SPECs for ${project.id}:`, error)
1340
+ }
1341
+ }
1342
+
1343
+ res.json({
1344
+ success: true,
1345
+ data: {
1346
+ synced: totalCreated + totalUpdated,
1347
+ created: totalCreated,
1348
+ updated: totalUpdated,
1349
+ projects: projectsSynced,
1350
+ },
1351
+ })
1352
+ } catch (error) {
1353
+ console.error('Error syncing flow changes:', error)
1354
+ res.status(500).json({ success: false, error: 'Failed to sync flow changes' })
1355
+ }
1356
+ })
1357
+
1358
+ // GET /tasks - Flow Tasks 목록 (필터링)
1359
+ flowRouter.get('/tasks', async (req, res) => {
1360
+ try {
1361
+ await initTaskDb()
1362
+ const { changeId, stage, status, standalone, includeArchived, projectId, origin } = req.query
1363
+
1364
+ // projectId 쿼리 파라미터가 있으면 해당 프로젝트 사용, 없으면 활성 프로젝트
1365
+ const project = projectId ? await getProjectById(projectId as string) : await getActiveProject()
1366
+
1367
+ if (!project) {
1368
+ return res.status(400).json({ success: false, error: 'No active project' })
1369
+ }
1370
+
1371
+ const sqlite = getSqlite()
1372
+ let sql = 'SELECT * FROM tasks WHERE project_id = ?'
1373
+ const params: unknown[] = [project.id]
1374
+
1375
+ if (standalone === 'true') {
1376
+ sql += ' AND change_id IS NULL'
1377
+ } else if (changeId) {
1378
+ sql += ' AND change_id = ?'
1379
+ params.push(changeId)
1380
+ }
1381
+
1382
+ // origin 필터 지원 (backlog, inbox, openspec, imported)
1383
+ if (origin) {
1384
+ sql += ' AND origin = ?'
1385
+ params.push(origin)
1386
+ }
1387
+
1388
+ if (stage) {
1389
+ sql += ' AND stage = ?'
1390
+ params.push(stage)
1391
+ }
1392
+
1393
+ if (status) {
1394
+ sql += ' AND status = ?'
1395
+ params.push(status)
1396
+ } else if (includeArchived !== 'true') {
1397
+ sql += " AND status != 'archived'"
1398
+ }
1399
+
1400
+ sql += ' ORDER BY "order", created_at'
1401
+
1402
+ const tasks = sqlite.prepare(sql).all(...params) as Array<{
1403
+ id: number
1404
+ change_id: string | null
1405
+ stage: Stage
1406
+ origin: TaskOrigin | null
1407
+ title: string
1408
+ description: string | null
1409
+ status: string
1410
+ priority: string
1411
+ tags: string | null
1412
+ assignee: string | null
1413
+ order: number
1414
+ display_id: string | null
1415
+ // Backlog 확장 필드
1416
+ parent_task_id: number | null
1417
+ blocked_by: string | null
1418
+ plan: string | null
1419
+ acceptance_criteria: string | null
1420
+ notes: string | null
1421
+ due_date: number | null
1422
+ milestone: string | null
1423
+ backlog_file_id: string | null
1424
+ created_at: number
1425
+ updated_at: number
1426
+ archived_at: number | null
1427
+ }>
1428
+
1429
+ const formatted = tasks.map((t) => ({
1430
+ id: t.id,
1431
+ changeId: t.change_id,
1432
+ stage: t.stage,
1433
+ origin: t.origin,
1434
+ title: t.title,
1435
+ description: t.description,
1436
+ status: t.status,
1437
+ priority: t.priority,
1438
+ tags: t.tags ? JSON.parse(t.tags) : [],
1439
+ assignee: t.assignee,
1440
+ order: t.order,
1441
+ displayId: t.display_id,
1442
+ // Backlog 확장 필드
1443
+ parentTaskId: t.parent_task_id,
1444
+ blockedBy: t.blocked_by ? JSON.parse(t.blocked_by) : null,
1445
+ plan: t.plan,
1446
+ acceptanceCriteria: t.acceptance_criteria,
1447
+ notes: t.notes,
1448
+ dueDate: t.due_date ? new Date(t.due_date).toISOString() : null,
1449
+ milestone: t.milestone,
1450
+ backlogFileId: t.backlog_file_id,
1451
+ createdAt: new Date(t.created_at).toISOString(),
1452
+ updatedAt: new Date(t.updated_at).toISOString(),
1453
+ archivedAt: t.archived_at ? new Date(t.archived_at).toISOString() : null,
1454
+ }))
1455
+
1456
+ res.json({ success: true, data: { tasks: formatted } })
1457
+ } catch (error) {
1458
+ console.error('Error listing flow tasks:', error)
1459
+ res.status(500).json({ success: false, error: 'Failed to list flow tasks' })
1460
+ }
1461
+ })
1462
+
1463
+ // POST /tasks - Flow Task 생성
1464
+ flowRouter.post('/tasks', async (req, res) => {
1465
+ try {
1466
+ await initTaskDb()
1467
+ const project = await getActiveProject()
1468
+ if (!project) {
1469
+ return res.status(400).json({ success: false, error: 'No active project' })
1470
+ }
1471
+ const { changeId, stage, title, description, priority } = req.body
1472
+
1473
+ if (!title) {
1474
+ return res.status(400).json({ success: false, error: 'Title is required' })
1475
+ }
1476
+
1477
+ const sqlite = getSqlite()
1478
+ const now = Date.now()
1479
+
1480
+ const result = sqlite
1481
+ .prepare(
1482
+ `
1483
+ INSERT INTO tasks (project_id, change_id, stage, title, description, status, priority, "order", created_at, updated_at)
1484
+ VALUES (?, ?, ?, ?, ?, 'todo', ?, 0, ?, ?)
1485
+ `
1486
+ )
1487
+ .run(
1488
+ project.id,
1489
+ changeId || null,
1490
+ stage || 'task',
1491
+ title,
1492
+ description || null,
1493
+ priority || 'medium',
1494
+ now,
1495
+ now
1496
+ )
1497
+
1498
+ const task = sqlite.prepare('SELECT * FROM tasks WHERE id = ?').get(result.lastInsertRowid)
1499
+
1500
+ emit('task:created', { task })
1501
+
1502
+ res.json({ success: true, data: { task } })
1503
+ } catch (error) {
1504
+ console.error('Error creating flow task:', error)
1505
+ res.status(500).json({ success: false, error: 'Failed to create flow task' })
1506
+ }
1507
+ })
1508
+
1509
+ // GET /changes/:id/proposal - Change의 proposal.md 내용
1510
+ flowRouter.get('/changes/:id/proposal', async (req, res) => {
1511
+ try {
1512
+ const changeId = req.params.id
1513
+ const project = await getProjectForChange(changeId)
1514
+
1515
+ if (!project) {
1516
+ return res.status(400).json({ success: false, error: 'Project not found for change' })
1517
+ }
1518
+
1519
+ const proposalPath = `${project.path}/openspec/changes/${changeId}/proposal.md`
1520
+ const archiveDir = `${project.path}/openspec/changes/archive`
1521
+
1522
+ // Remote project: use SSH plugin
1523
+ if (project.remote) {
1524
+ const plugin = await getRemotePlugin()
1525
+ if (!plugin) {
1526
+ return res.json({ success: true, data: { changeId, content: null } })
1527
+ }
1528
+
1529
+ const server = await plugin.getRemoteServerById(project.remote.serverId)
1530
+ if (!server) {
1531
+ return res.json({ success: true, data: { changeId, content: null } })
1532
+ }
1533
+
1534
+ // Try active changes folder first
1535
+ try {
1536
+ const content = await plugin.readRemoteFile(server, proposalPath)
1537
+ return res.json({ success: true, data: { changeId, content } })
1538
+ } catch {
1539
+ // Fallback: try archive folder
1540
+ const archiveFolderPath = await findRemoteArchivePath(plugin, server, archiveDir, changeId)
1541
+ if (archiveFolderPath) {
1542
+ try {
1543
+ const content = await plugin.readRemoteFile(server, `${archiveFolderPath}/proposal.md`)
1544
+ return res.json({ success: true, data: { changeId, content } })
1545
+ } catch {
1546
+ return res.json({ success: true, data: { changeId, content: null } })
1547
+ }
1548
+ }
1549
+ return res.json({ success: true, data: { changeId, content: null } })
1550
+ }
1551
+ }
1552
+
1553
+ // Local project: use filesystem
1554
+ try {
1555
+ const content = await readFile(proposalPath, 'utf-8')
1556
+ res.json({ success: true, data: { changeId, content } })
1557
+ } catch {
1558
+ // Fallback: try archive folder
1559
+ const archivePath = join(project.path, 'openspec', 'changes', 'archive', changeId, 'proposal.md')
1560
+ try {
1561
+ const content = await readFile(archivePath, 'utf-8')
1562
+ res.json({ success: true, data: { changeId, content } })
1563
+ } catch {
1564
+ res.json({ success: true, data: { changeId, content: null } })
1565
+ }
1566
+ }
1567
+ } catch (error) {
1568
+ console.error('Error reading proposal:', error)
1569
+ res.status(500).json({ success: false, error: 'Failed to read proposal' })
1570
+ }
1571
+ })
1572
+
1573
+ // GET /changes/:id/design - Change의 design.md 내용
1574
+ flowRouter.get('/changes/:id/design', async (req, res) => {
1575
+ try {
1576
+ const changeId = req.params.id
1577
+ const project = await getProjectForChange(changeId)
1578
+
1579
+ if (!project) {
1580
+ return res.status(400).json({ success: false, error: 'Project not found for change' })
1581
+ }
1582
+
1583
+ const designPath = `${project.path}/openspec/changes/${changeId}/design.md`
1584
+ const archiveDir = `${project.path}/openspec/changes/archive`
1585
+
1586
+ // Remote project: use SSH plugin
1587
+ if (project.remote) {
1588
+ const plugin = await getRemotePlugin()
1589
+ if (!plugin) {
1590
+ return res.json({ success: true, data: { changeId, content: null } })
1591
+ }
1592
+
1593
+ const server = await plugin.getRemoteServerById(project.remote.serverId)
1594
+ if (!server) {
1595
+ return res.json({ success: true, data: { changeId, content: null } })
1596
+ }
1597
+
1598
+ // Try active changes folder first
1599
+ try {
1600
+ const content = await plugin.readRemoteFile(server, designPath)
1601
+ return res.json({ success: true, data: { changeId, content } })
1602
+ } catch {
1603
+ // Fallback: try archive folder
1604
+ const archiveFolderPath = await findRemoteArchivePath(plugin, server, archiveDir, changeId)
1605
+ if (archiveFolderPath) {
1606
+ try {
1607
+ const content = await plugin.readRemoteFile(server, `${archiveFolderPath}/design.md`)
1608
+ return res.json({ success: true, data: { changeId, content } })
1609
+ } catch {
1610
+ return res.json({ success: true, data: { changeId, content: null } })
1611
+ }
1612
+ }
1613
+ return res.json({ success: true, data: { changeId, content: null } })
1614
+ }
1615
+ }
1616
+
1617
+ // Local project: use filesystem
1618
+ try {
1619
+ const content = await readFile(designPath, 'utf-8')
1620
+ res.json({ success: true, data: { changeId, content } })
1621
+ } catch {
1622
+ // Fallback: try archive folder
1623
+ const archivePath = join(project.path, 'openspec', 'changes', 'archive', changeId, 'design.md')
1624
+ try {
1625
+ const content = await readFile(archivePath, 'utf-8')
1626
+ res.json({ success: true, data: { changeId, content } })
1627
+ } catch {
1628
+ res.json({ success: true, data: { changeId, content: null } })
1629
+ }
1630
+ }
1631
+ } catch (error) {
1632
+ console.error('Error reading design:', error)
1633
+ res.status(500).json({ success: false, error: 'Failed to read design' })
1634
+ }
1635
+ })
1636
+
1637
+ // GET /changes/:id/spec - Change의 첫 번째 spec.md 내용
1638
+ flowRouter.get('/changes/:id/spec', async (req, res) => {
1639
+ try {
1640
+ const changeId = req.params.id
1641
+ const project = await getProjectForChange(changeId)
1642
+
1643
+ if (!project) {
1644
+ return res.status(400).json({ success: false, error: 'Project not found for change' })
1645
+ }
1646
+
1647
+ const specsDir = `${project.path}/openspec/changes/${changeId}/specs`
1648
+ const archiveDir = `${project.path}/openspec/changes/archive`
1649
+
1650
+ // Remote project: use SSH plugin
1651
+ if (project.remote) {
1652
+ const plugin = await getRemotePlugin()
1653
+ if (!plugin) {
1654
+ return res.json({ success: true, data: { changeId, content: null, specId: null } })
1655
+ }
1656
+
1657
+ const server = await plugin.getRemoteServerById(project.remote.serverId)
1658
+ if (!server) {
1659
+ return res.json({ success: true, data: { changeId, content: null, specId: null } })
1660
+ }
1661
+
1662
+ // Try active changes folder first
1663
+ try {
1664
+ const listing = await plugin.listDirectory(server, specsDir)
1665
+ const specFolders = listing.entries
1666
+ .filter((e) => e.type === 'directory')
1667
+ .map((e) => e.name)
1668
+
1669
+ if (specFolders.length > 0) {
1670
+ const firstSpecId = specFolders[0]
1671
+ const specPath = `${specsDir}/${firstSpecId}/spec.md`
1672
+ const content = await plugin.readRemoteFile(server, specPath)
1673
+ return res.json({ success: true, data: { changeId, content, specId: firstSpecId } })
1674
+ }
1675
+ } catch {
1676
+ // Active folder not found, try archive
1677
+ }
1678
+
1679
+ // Fallback: try archive folder
1680
+ const archiveFolderPath = await findRemoteArchivePath(plugin, server, archiveDir, changeId)
1681
+ if (archiveFolderPath) {
1682
+ try {
1683
+ const archiveSpecsDir = `${archiveFolderPath}/specs`
1684
+ const listing = await plugin.listDirectory(server, archiveSpecsDir)
1685
+ const specFolders = listing.entries
1686
+ .filter((e) => e.type === 'directory')
1687
+ .map((e) => e.name)
1688
+
1689
+ if (specFolders.length > 0) {
1690
+ const firstSpecId = specFolders[0]
1691
+ const specPath = `${archiveSpecsDir}/${firstSpecId}/spec.md`
1692
+ const content = await plugin.readRemoteFile(server, specPath)
1693
+ return res.json({ success: true, data: { changeId, content, specId: firstSpecId } })
1694
+ }
1695
+ } catch {
1696
+ // Archive specs not found
1697
+ }
1698
+ }
1699
+
1700
+ return res.json({ success: true, data: { changeId, content: null, specId: null } })
1701
+ }
1702
+
1703
+ // Local project: use filesystem
1704
+ try {
1705
+ const specFolders = await readdir(specsDir)
1706
+ if (specFolders.length > 0) {
1707
+ const firstSpecId = specFolders[0]
1708
+ const specPath = join(specsDir, firstSpecId, 'spec.md')
1709
+ const content = await readFile(specPath, 'utf-8')
1710
+ return res.json({ success: true, data: { changeId, content, specId: firstSpecId } })
1711
+ }
1712
+ } catch {
1713
+ // Active folder not found, try archive
1714
+ }
1715
+
1716
+ // Fallback: try archive folder
1717
+ try {
1718
+ const archiveSpecsDir = join(project.path, 'openspec', 'changes', 'archive', changeId, 'specs')
1719
+ const specFolders = await readdir(archiveSpecsDir)
1720
+ if (specFolders.length > 0) {
1721
+ const firstSpecId = specFolders[0]
1722
+ const specPath = join(archiveSpecsDir, firstSpecId, 'spec.md')
1723
+ const content = await readFile(specPath, 'utf-8')
1724
+ return res.json({ success: true, data: { changeId, content, specId: firstSpecId } })
1725
+ }
1726
+ } catch {
1727
+ // Archive specs not found
1728
+ }
1729
+
1730
+ res.json({ success: true, data: { changeId, content: null, specId: null } })
1731
+ } catch (error) {
1732
+ console.error('Error reading spec:', error)
1733
+ res.status(500).json({ success: false, error: 'Failed to read spec' })
1734
+ }
1735
+ })
1736
+
1737
+ // GET /changes/:changeId/specs/:specId - 특정 spec.md 내용
1738
+ flowRouter.get('/changes/:changeId/specs/:specId', async (req, res) => {
1739
+ try {
1740
+ const { changeId, specId } = req.params
1741
+ const project = await getProjectForChange(changeId)
1742
+
1743
+ if (!project) {
1744
+ return res.status(400).json({ success: false, error: 'Project not found for change' })
1745
+ }
1746
+
1747
+ const changeSpecPath = `${project.path}/openspec/changes/${changeId}/specs/${specId}/spec.md`
1748
+ const archivedSpecPath = `${project.path}/openspec/specs/${specId}/spec.md`
1749
+
1750
+ // Remote project: use SSH plugin
1751
+ if (project.remote) {
1752
+ const plugin = await getRemotePlugin()
1753
+ if (!plugin) {
1754
+ return res.json({ success: true, data: { specId, content: null, location: null } })
1755
+ }
1756
+
1757
+ const server = await plugin.getRemoteServerById(project.remote.serverId)
1758
+ if (!server) {
1759
+ return res.json({ success: true, data: { specId, content: null, location: null } })
1760
+ }
1761
+
1762
+ // Try change specs first
1763
+ try {
1764
+ const content = await plugin.readRemoteFile(server, changeSpecPath)
1765
+ return res.json({ success: true, data: { specId, content, location: 'change' } })
1766
+ } catch {
1767
+ // Not found in change, try archived
1768
+ }
1769
+
1770
+ // Try archived specs
1771
+ try {
1772
+ const content = await plugin.readRemoteFile(server, archivedSpecPath)
1773
+ return res.json({ success: true, data: { specId, content, location: 'archived' } })
1774
+ } catch {
1775
+ return res.json({ success: true, data: { specId, content: null, location: null } })
1776
+ }
1777
+ }
1778
+
1779
+ // Local project: use filesystem
1780
+ try {
1781
+ const content = await readFile(changeSpecPath, 'utf-8')
1782
+ return res.json({ success: true, data: { specId, content, location: 'change' } })
1783
+ } catch {
1784
+ // Change 내에 없으면 archived specs에서 찾기
1785
+ }
1786
+
1787
+ try {
1788
+ const content = await readFile(archivedSpecPath, 'utf-8')
1789
+ return res.json({ success: true, data: { specId, content, location: 'archived' } })
1790
+ } catch {
1791
+ return res.json({ success: true, data: { specId, content: null, location: null } })
1792
+ }
1793
+ } catch (error) {
1794
+ console.error('Error reading change spec:', error)
1795
+ res.status(500).json({ success: false, error: 'Failed to read spec' })
1796
+ }
1797
+ })
1798
+
1799
+ // PATCH /tasks/:id - Flow Task 수정
1800
+ flowRouter.patch('/tasks/:id', async (req, res) => {
1801
+ try {
1802
+ await initTaskDb()
1803
+ const { changeId, stage, title, description, status, priority, order } = req.body
1804
+
1805
+ const sqlite = getSqlite()
1806
+ const existing = sqlite.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id)
1807
+
1808
+ if (!existing) {
1809
+ return res.status(404).json({ success: false, error: 'Task not found' })
1810
+ }
1811
+
1812
+ const updates: string[] = []
1813
+ const params: unknown[] = []
1814
+
1815
+ if (changeId !== undefined) {
1816
+ updates.push('change_id = ?')
1817
+ params.push(changeId)
1818
+ }
1819
+ if (stage !== undefined) {
1820
+ updates.push('stage = ?')
1821
+ params.push(stage)
1822
+ }
1823
+ if (title !== undefined) {
1824
+ updates.push('title = ?')
1825
+ params.push(title)
1826
+ }
1827
+ if (description !== undefined) {
1828
+ updates.push('description = ?')
1829
+ params.push(description)
1830
+ }
1831
+ if (status !== undefined) {
1832
+ updates.push('status = ?')
1833
+ params.push(status)
1834
+ }
1835
+ if (priority !== undefined) {
1836
+ updates.push('priority = ?')
1837
+ params.push(priority)
1838
+ }
1839
+ if (order !== undefined) {
1840
+ updates.push('"order" = ?')
1841
+ params.push(order)
1842
+ }
1843
+
1844
+ updates.push('updated_at = ?')
1845
+ params.push(Date.now())
1846
+ params.push(req.params.id)
1847
+
1848
+ sqlite.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...params)
1849
+
1850
+ const task = sqlite.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id)
1851
+
1852
+ emit('task:updated', { task })
1853
+
1854
+ res.json({ success: true, data: { task } })
1855
+ } catch (error) {
1856
+ console.error('Error updating flow task:', error)
1857
+ res.status(500).json({ success: false, error: 'Failed to update flow task' })
1858
+ }
1859
+ })
1860
+
1861
+ // POST /changes/:id/sync - 특정 Change의 tasks.md를 DB에 수동 동기화
1862
+ flowRouter.post('/changes/:id/sync', async (req, res) => {
1863
+ try {
1864
+ await initTaskDb()
1865
+ const changeId = req.params.id
1866
+ const project = await getActiveProject()
1867
+
1868
+ if (!project) {
1869
+ return res.status(400).json({ success: false, error: 'No active project' })
1870
+ }
1871
+
1872
+ let result: { tasksCreated: number; tasksUpdated: number }
1873
+
1874
+ // 원격 프로젝트인 경우 SSH를 통해 동기화
1875
+ if (project.remote?.serverId) {
1876
+ const plugin = await getRemotePlugin()
1877
+ if (!plugin) {
1878
+ return res.status(400).json({ success: false, error: 'Remote plugin not installed' })
1879
+ }
1880
+ const server = await plugin.getRemoteServerById(project.remote.serverId)
1881
+ if (!server) {
1882
+ return res.status(400).json({ success: false, error: 'Remote server not found' })
1883
+ }
1884
+ result = await syncRemoteChangeTasksForProject(changeId, project.path, server, project.id)
1885
+ } else {
1886
+ // 로컬 프로젝트
1887
+ result = await syncChangeTasksFromFile(changeId)
1888
+ }
1889
+
1890
+ emit('change:synced', {
1891
+ changeId,
1892
+ projectPath: project.path,
1893
+ tasksCreated: result.tasksCreated,
1894
+ tasksUpdated: result.tasksUpdated,
1895
+ })
1896
+
1897
+ res.json({
1898
+ success: true,
1899
+ data: {
1900
+ changeId,
1901
+ tasksCreated: result.tasksCreated,
1902
+ tasksUpdated: result.tasksUpdated,
1903
+ },
1904
+ })
1905
+ } catch (error) {
1906
+ console.error('Error syncing change:', error)
1907
+ res.status(500).json({
1908
+ success: false,
1909
+ error: error instanceof Error ? error.message : 'Failed to sync change',
1910
+ })
1911
+ }
1912
+ })
1913
+
1914
+ // POST /sync/all - 모든 프로젝트의 모든 Changes 동기화
1915
+ flowRouter.post('/sync/all', async (_req, res) => {
1916
+ try {
1917
+ await initTaskDb()
1918
+ const config = await loadConfig()
1919
+
1920
+ if (config.projects.length === 0) {
1921
+ return res.json({
1922
+ success: true,
1923
+ data: { synced: 0, created: 0, updated: 0, projects: 0 },
1924
+ })
1925
+ }
1926
+
1927
+ let totalCreated = 0
1928
+ let totalUpdated = 0
1929
+ let projectsSynced = 0
1930
+
1931
+ for (const project of config.projects) {
1932
+ const openspecDir = join(project.path, 'openspec', 'changes')
1933
+ let entries
1934
+ try {
1935
+ entries = await readdir(openspecDir, { withFileTypes: true })
1936
+ } catch {
1937
+ continue
1938
+ }
1939
+
1940
+ projectsSynced++
1941
+
1942
+ for (const entry of entries) {
1943
+ if (!entry.isDirectory() || entry.name === 'archive') continue
1944
+
1945
+ const changeId = entry.name
1946
+ try {
1947
+ const result = await syncChangeTasksForProject(changeId, project.path, project.id)
1948
+ totalCreated += result.tasksCreated
1949
+ totalUpdated += result.tasksUpdated
1950
+ } catch (syncError) {
1951
+ console.error(`Error syncing ${changeId}:`, syncError)
1952
+ }
1953
+ }
1954
+ }
1955
+
1956
+ emit('sync:completed', {
1957
+ totalCreated,
1958
+ totalUpdated,
1959
+ projectsSynced,
1960
+ })
1961
+
1962
+ res.json({
1963
+ success: true,
1964
+ data: {
1965
+ synced: totalCreated + totalUpdated,
1966
+ created: totalCreated,
1967
+ updated: totalUpdated,
1968
+ projects: projectsSynced,
1969
+ },
1970
+ })
1971
+ } catch (error) {
1972
+ console.error('Error syncing all changes:', error)
1973
+ res.status(500).json({
1974
+ success: false,
1975
+ error: error instanceof Error ? error.message : 'Failed to sync all changes',
1976
+ })
1977
+ }
1978
+ })
1979
+
1980
+ // POST /changes/:id/archive - Change를 아카이브로 이동
1981
+ flowRouter.post('/changes/:id/archive', async (req, res) => {
1982
+ try {
1983
+ const changeId = req.params.id
1984
+ const { skipSpecs, force, projectId } = req.body
1985
+
1986
+ // projectId가 있으면 해당 프로젝트 사용, 없으면 활성 프로젝트
1987
+ const project = projectId ? await getProjectById(projectId) : await getActiveProject()
1988
+
1989
+ if (!project) {
1990
+ return res.status(400).json({ success: false, error: 'No active project' })
1991
+ }
1992
+
1993
+ const args = ['archive', changeId, '-y']
1994
+ if (skipSpecs) {
1995
+ args.push('--skip-specs')
1996
+ }
1997
+ if (force) {
1998
+ args.push('--no-validate')
1999
+ }
2000
+
2001
+ let stdout = ''
2002
+ let stderr = ''
2003
+ let validationFailed = false
2004
+ const validationErrors: string[] = []
2005
+ let filesMoved = false
2006
+
2007
+ // 원격 프로젝트인 경우 SSH를 통해 아카이브 실행
2008
+ if (project.remote) {
2009
+ const plugin = await getRemotePlugin()
2010
+ if (!plugin) {
2011
+ return res.status(400).json({ success: false, error: 'Remote plugin not installed' })
2012
+ }
2013
+
2014
+ const server = await plugin.getRemoteServerById(project.remote.serverId)
2015
+ if (!server) {
2016
+ return res.status(400).json({ success: false, error: 'Remote server not found' })
2017
+ }
2018
+
2019
+ // Remote plugin functions
2020
+ const executeCommand = (plugin as unknown as { executeCommand: (s: unknown, cmd: string) => Promise<{ stdout: string }> }).executeCommand
2021
+ const exists = async (s: unknown, p: string) => {
2022
+ try {
2023
+ await executeCommand(s, `test -e "${p}"`)
2024
+ return true
2025
+ } catch {
2026
+ return false
2027
+ }
2028
+ }
2029
+
2030
+ // 원격에서 openspec archive 명령 실행 또는 직접 mv 명령 실행
2031
+ const archivePath = `${project.path}/openspec/changes/archive`
2032
+ const sourcePath = `${project.path}/openspec/changes/${changeId}`
2033
+ const targetPath = `${archivePath}/${new Date().toISOString().split('T')[0]}-${changeId}`
2034
+
2035
+ try {
2036
+ // 먼저 archive 폴더 존재 확인
2037
+ const archiveExists = await exists(server, archivePath)
2038
+ if (!archiveExists) {
2039
+ await executeCommand(server, `mkdir -p "${archivePath}"`, { cwd: project.path })
2040
+ }
2041
+
2042
+ // 소스 폴더 존재 확인
2043
+ const sourceExists = await exists(server, sourcePath)
2044
+ if (!sourceExists) {
2045
+ return res.status(404).json({
2046
+ success: false,
2047
+ error: `Change folder not found: ${changeId}`,
2048
+ })
2049
+ }
2050
+
2051
+ // mv 명령으로 아카이브 폴더로 이동
2052
+ const mvResult = await executeCommand(server, `mv "${sourcePath}" "${targetPath}"`, {
2053
+ cwd: project.path,
2054
+ })
2055
+
2056
+ stdout = mvResult.stdout
2057
+ stderr = mvResult.stderr
2058
+
2059
+ if (mvResult.exitCode === 0) {
2060
+ filesMoved = true
2061
+ console.log(`[Archive] Remote archive successful: ${changeId} -> ${targetPath}`)
2062
+ } else {
2063
+ throw new Error(`Failed to move change folder: ${stderr}`)
2064
+ }
2065
+ } catch (execError) {
2066
+ const error = execError as { stdout?: string; stderr?: string; message?: string }
2067
+ console.error('[Archive] Remote archive error:', error)
2068
+ throw new Error(error.message || 'Failed to archive change on remote server')
2069
+ }
2070
+ } else {
2071
+ // 로컬 프로젝트인 경우 기존 로직 사용
2072
+ try {
2073
+ const result = await execAsync(`openspec ${args.join(' ')}`, {
2074
+ cwd: project.path,
2075
+ })
2076
+ stdout = result.stdout
2077
+ stderr = result.stderr
2078
+ } catch (execError) {
2079
+ const error = execError as { stdout?: string; stderr?: string; message?: string }
2080
+ stdout = error.stdout || ''
2081
+ stderr = error.stderr || ''
2082
+
2083
+ if (stdout.includes('Validation failed') || stdout.includes('Validation errors')) {
2084
+ validationFailed = true
2085
+ const lines = stdout.split('\n')
2086
+ for (const line of lines) {
2087
+ if (line.includes('✗') || line.includes('⚠')) {
2088
+ validationErrors.push(line.trim())
2089
+ }
2090
+ }
2091
+ } else {
2092
+ throw execError
2093
+ }
2094
+ }
2095
+
2096
+ if (validationFailed && !force) {
2097
+ console.log(`[Archive] Validation failed for ${changeId}, returning error to client`)
2098
+ return res.status(422).json({
2099
+ success: false,
2100
+ error: 'Validation failed',
2101
+ validationErrors,
2102
+ canForce: true,
2103
+ hint: 'Use force option to archive without validation, or fix the spec errors first',
2104
+ })
2105
+ }
2106
+
2107
+ const archivePath = join(project.path, 'openspec', 'changes', 'archive', changeId)
2108
+ const originalPath = join(project.path, 'openspec', 'changes', changeId)
2109
+
2110
+ try {
2111
+ await access(archivePath)
2112
+ filesMoved = true
2113
+ } catch {
2114
+ try {
2115
+ await access(originalPath)
2116
+ filesMoved = false
2117
+ } catch {
2118
+ filesMoved = false
2119
+ }
2120
+ }
2121
+ }
2122
+
2123
+ // DB 업데이트 (로컬/원격 공통)
2124
+ await initTaskDb()
2125
+ const sqlite = getSqlite()
2126
+ const now = Date.now()
2127
+
2128
+ sqlite
2129
+ .prepare(
2130
+ `
2131
+ UPDATE changes SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ? AND project_id = ?
2132
+ `
2133
+ )
2134
+ .run(now, now, changeId, project.id)
2135
+
2136
+ // tasks도 archived로 업데이트
2137
+ sqlite
2138
+ .prepare(
2139
+ `
2140
+ UPDATE tasks SET status = 'archived', archived_at = ?, updated_at = ? WHERE change_id = ? AND project_id = ?
2141
+ `
2142
+ )
2143
+ .run(now, now, changeId, project.id)
2144
+
2145
+ emit('change:archived', { changeId, projectId: project.id })
2146
+
2147
+ res.json({
2148
+ success: true,
2149
+ data: {
2150
+ changeId,
2151
+ archived: true,
2152
+ filesMoved,
2153
+ stdout: stdout.trim(),
2154
+ stderr: stderr.trim(),
2155
+ },
2156
+ })
2157
+ } catch (error) {
2158
+ console.error('Error archiving change:', error)
2159
+ res.status(500).json({
2160
+ success: false,
2161
+ error: error instanceof Error ? error.message : 'Failed to archive change',
2162
+ })
2163
+ }
2164
+ })
2165
+
2166
+ // POST /changes/:id/fix-validation - 자동으로 validation 에러 수정
2167
+ flowRouter.post('/changes/:id/fix-validation', async (req, res) => {
2168
+ try {
2169
+ const changeId = req.params.id
2170
+ const project = await getActiveProject()
2171
+
2172
+ if (!project) {
2173
+ return res.status(400).json({ success: false, error: 'No active project' })
2174
+ }
2175
+
2176
+ const changeDir = join(project.path, 'openspec', 'changes', changeId)
2177
+ const fs = await import('fs/promises')
2178
+
2179
+ const proposalPath = join(changeDir, 'proposal.md')
2180
+ let proposalFixed = false
2181
+ try {
2182
+ const content = await fs.readFile(proposalPath, 'utf-8')
2183
+ const lines = content.split('\n')
2184
+ const fixedLines = lines.map((line) => {
2185
+ if (line.match(/^[-*]\s+/) && !line.match(/\b(SHALL|MUST)\b/i)) {
2186
+ if (line.match(/^[-*]\s+\w/)) {
2187
+ return line.replace(/^([-*]\s+)/, '$1The system SHALL ')
2188
+ }
2189
+ }
2190
+ return line
2191
+ })
2192
+ const newContent = fixedLines.join('\n')
2193
+ if (newContent !== content) {
2194
+ await fs.writeFile(proposalPath, newContent)
2195
+ proposalFixed = true
2196
+ }
2197
+ } catch {
2198
+ // proposal.md not found or not readable
2199
+ }
2200
+
2201
+ const specsDir = join(changeDir, 'specs')
2202
+ let specsFixed = 0
2203
+ try {
2204
+ const specEntries = await fs.readdir(specsDir, { withFileTypes: true })
2205
+ for (const entry of specEntries) {
2206
+ if (!entry.isDirectory()) continue
2207
+ const specPath = join(specsDir, entry.name, 'spec.md')
2208
+ try {
2209
+ const content = await fs.readFile(specPath, 'utf-8')
2210
+ const lines = content.split('\n')
2211
+ const fixedLines = lines.map((line) => {
2212
+ if (line.match(/^###\s+Requirement:/)) {
2213
+ if (!line.match(/\b(SHALL|MUST)\b/i)) {
2214
+ return line.replace(/(###\s+Requirement:\s*)(.+)/, (_, prefix, reqText) => {
2215
+ return `${prefix}The system SHALL provide ${reqText}`
2216
+ })
2217
+ }
2218
+ }
2219
+ return line
2220
+ })
2221
+ const newContent = fixedLines.join('\n')
2222
+ if (newContent !== content) {
2223
+ await fs.writeFile(specPath, newContent)
2224
+ specsFixed++
2225
+ }
2226
+ } catch {
2227
+ // spec.md not found
2228
+ }
2229
+ }
2230
+ } catch {
2231
+ // specs directory not found
2232
+ }
2233
+
2234
+ res.json({
2235
+ success: true,
2236
+ data: {
2237
+ changeId,
2238
+ proposalFixed,
2239
+ specsFixed,
2240
+ message:
2241
+ proposalFixed || specsFixed > 0
2242
+ ? `Fixed validation errors: proposal=${proposalFixed}, specs=${specsFixed}`
2243
+ : 'No fixes needed or possible',
2244
+ },
2245
+ })
2246
+ } catch (error) {
2247
+ console.error('Error fixing validation:', error)
2248
+ res.status(500).json({
2249
+ success: false,
2250
+ error: error instanceof Error ? error.message : 'Failed to fix validation',
2251
+ })
2252
+ }
2253
+ })
2254
+
2255
+ // =============================================
2256
+ // Backlog API 엔드포인트
2257
+ // =============================================
2258
+
2259
+ // POST /backlog/sync - Backlog 파일을 DB에 동기화
2260
+ flowRouter.post('/backlog/sync', async (_req, res) => {
2261
+ try {
2262
+ await initTaskDb()
2263
+ const project = await getActiveProject()
2264
+
2265
+ if (!project) {
2266
+ return res.status(400).json({ success: false, error: 'No active project' })
2267
+ }
2268
+
2269
+ const result = await syncBacklogToDb(project.id, project.path)
2270
+
2271
+ emit('backlog:synced', {
2272
+ projectId: project.id,
2273
+ ...result,
2274
+ })
2275
+
2276
+ res.json({
2277
+ success: true,
2278
+ data: result,
2279
+ })
2280
+ } catch (error) {
2281
+ console.error('Error syncing backlog:', error)
2282
+ res.status(500).json({
2283
+ success: false,
2284
+ error: error instanceof Error ? error.message : 'Failed to sync backlog',
2285
+ })
2286
+ }
2287
+ })
2288
+
2289
+ // POST /backlog/tasks - Backlog 태스크 생성 (마크다운 파일 생성)
2290
+ flowRouter.post('/backlog/tasks', async (req, res) => {
2291
+ try {
2292
+ await initTaskDb()
2293
+ const project = await getActiveProject()
2294
+
2295
+ if (!project) {
2296
+ return res.status(400).json({ success: false, error: 'No active project' })
2297
+ }
2298
+
2299
+ const {
2300
+ title,
2301
+ description,
2302
+ status = 'todo',
2303
+ priority = 'medium',
2304
+ assignees,
2305
+ labels,
2306
+ blockedBy,
2307
+ parent,
2308
+ dueDate,
2309
+ milestone,
2310
+ plan,
2311
+ acceptanceCriteria,
2312
+ notes,
2313
+ } = req.body
2314
+
2315
+ if (!title) {
2316
+ return res.status(400).json({ success: false, error: 'Title is required' })
2317
+ }
2318
+
2319
+ // 새 backlogFileId 생성
2320
+ const backlogFileId = await generateNewBacklogTaskId(project.path)
2321
+
2322
+ // BacklogTask 객체 생성
2323
+ const task: BacklogTask = {
2324
+ backlogFileId,
2325
+ title,
2326
+ description,
2327
+ status,
2328
+ priority,
2329
+ assignees,
2330
+ labels,
2331
+ blockedBy,
2332
+ parent,
2333
+ dueDate,
2334
+ milestone,
2335
+ plan,
2336
+ acceptanceCriteria,
2337
+ notes,
2338
+ filePath: '', // saveTaskToBacklogFile에서 설정됨
2339
+ }
2340
+
2341
+ // 마크다운 파일 저장
2342
+ const filePath = await saveTaskToBacklogFile(project.path, task)
2343
+ task.filePath = filePath
2344
+
2345
+ // DB에 동기화
2346
+ const result = await syncBacklogToDb(project.id, project.path)
2347
+
2348
+ emit('backlog:task:created', {
2349
+ projectId: project.id,
2350
+ backlogFileId,
2351
+ filePath,
2352
+ })
2353
+
2354
+ res.json({
2355
+ success: true,
2356
+ data: {
2357
+ backlogFileId,
2358
+ filePath,
2359
+ synced: result,
2360
+ },
2361
+ })
2362
+ } catch (error) {
2363
+ console.error('Error creating backlog task:', error)
2364
+ res.status(500).json({
2365
+ success: false,
2366
+ error: error instanceof Error ? error.message : 'Failed to create backlog task',
2367
+ })
2368
+ }
2369
+ })
2370
+
2371
+ // PATCH /backlog/tasks/:backlogFileId - Backlog 태스크 수정 (마크다운 파일 수정)
2372
+ flowRouter.patch('/backlog/tasks/:backlogFileId', async (req, res) => {
2373
+ try {
2374
+ await initTaskDb()
2375
+ const project = await getActiveProject()
2376
+
2377
+ if (!project) {
2378
+ return res.status(400).json({ success: false, error: 'No active project' })
2379
+ }
2380
+
2381
+ const { backlogFileId } = req.params
2382
+ const updates = req.body
2383
+
2384
+ // 기존 태스크 조회
2385
+ const sqlite = getSqlite()
2386
+ const existing = sqlite
2387
+ .prepare(
2388
+ `
2389
+ SELECT * FROM tasks
2390
+ WHERE project_id = ? AND backlog_file_id = ? AND origin = 'backlog'
2391
+ `
2392
+ )
2393
+ .get(project.id, backlogFileId) as
2394
+ | {
2395
+ id: number
2396
+ title: string
2397
+ description: string | null
2398
+ status: string
2399
+ priority: string
2400
+ tags: string | null
2401
+ assignee: string | null
2402
+ parent_task_id: number | null
2403
+ blocked_by: string | null
2404
+ plan: string | null
2405
+ acceptance_criteria: string | null
2406
+ notes: string | null
2407
+ due_date: number | null
2408
+ milestone: string | null
2409
+ backlog_file_id: string
2410
+ }
2411
+ | undefined
2412
+
2413
+ if (!existing) {
2414
+ return res.status(404).json({ success: false, error: 'Backlog task not found' })
2415
+ }
2416
+
2417
+ // BacklogTask 객체 생성 (기존 + 업데이트)
2418
+ const task: BacklogTask = {
2419
+ backlogFileId: existing.backlog_file_id,
2420
+ title: updates.title ?? existing.title,
2421
+ description: updates.description ?? existing.description ?? undefined,
2422
+ status: updates.status ?? existing.status,
2423
+ priority: updates.priority ?? existing.priority,
2424
+ assignees: updates.assignees ?? (existing.assignee ? [existing.assignee] : undefined),
2425
+ labels: updates.labels ?? (existing.tags ? JSON.parse(existing.tags) : undefined),
2426
+ blockedBy:
2427
+ updates.blockedBy ?? (existing.blocked_by ? JSON.parse(existing.blocked_by) : undefined),
2428
+ parent: updates.parent,
2429
+ dueDate:
2430
+ updates.dueDate ??
2431
+ (existing.due_date ? new Date(existing.due_date).toISOString().split('T')[0] : undefined),
2432
+ milestone: updates.milestone ?? existing.milestone ?? undefined,
2433
+ plan: updates.plan ?? existing.plan ?? undefined,
2434
+ acceptanceCriteria: updates.acceptanceCriteria ?? existing.acceptance_criteria ?? undefined,
2435
+ notes: updates.notes ?? existing.notes ?? undefined,
2436
+ filePath: '',
2437
+ }
2438
+
2439
+ // 마크다운 파일 저장
2440
+ const filePath = await saveTaskToBacklogFile(project.path, task)
2441
+
2442
+ // DB에 동기화
2443
+ const result = await syncBacklogToDb(project.id, project.path)
2444
+
2445
+ emit('backlog:task:updated', {
2446
+ projectId: project.id,
2447
+ backlogFileId,
2448
+ filePath,
2449
+ })
2450
+
2451
+ res.json({
2452
+ success: true,
2453
+ data: {
2454
+ backlogFileId,
2455
+ filePath,
2456
+ synced: result,
2457
+ },
2458
+ })
2459
+ } catch (error) {
2460
+ console.error('Error updating backlog task:', error)
2461
+ res.status(500).json({
2462
+ success: false,
2463
+ error: error instanceof Error ? error.message : 'Failed to update backlog task',
2464
+ })
2465
+ }
2466
+ })
2467
+
2468
+ // DELETE /backlog/tasks/:backlogFileId - Backlog 태스크 삭제 (마크다운 파일 삭제)
2469
+ flowRouter.delete('/backlog/tasks/:backlogFileId', async (req, res) => {
2470
+ try {
2471
+ await initTaskDb()
2472
+ const project = await getActiveProject()
2473
+
2474
+ if (!project) {
2475
+ return res.status(400).json({ success: false, error: 'No active project' })
2476
+ }
2477
+
2478
+ const { backlogFileId } = req.params
2479
+ const { archive = false } = req.query
2480
+
2481
+ // 기존 태스크 조회
2482
+ const sqlite = getSqlite()
2483
+ const existing = sqlite
2484
+ .prepare(
2485
+ `
2486
+ SELECT id, title FROM tasks
2487
+ WHERE project_id = ? AND backlog_file_id = ? AND origin = 'backlog'
2488
+ `
2489
+ )
2490
+ .get(project.id, backlogFileId) as { id: number; title: string } | undefined
2491
+
2492
+ if (!existing) {
2493
+ return res.status(404).json({ success: false, error: 'Backlog task not found' })
2494
+ }
2495
+
2496
+ const backlogPath = getBacklogPath(project.path)
2497
+ const filename = generateBacklogFilename(backlogFileId, existing.title)
2498
+ const filePath = join(backlogPath, filename)
2499
+
2500
+ if (archive === 'true') {
2501
+ // 아카이브 폴더로 이동
2502
+ const archivePath = join(backlogPath, 'archive')
2503
+ await ensureBacklogDir(
2504
+ join(project.path, 'backlog', 'archive').replace('/backlog/archive', '')
2505
+ )
2506
+ try {
2507
+ await access(archivePath)
2508
+ } catch {
2509
+ const { mkdir } = await import('fs/promises')
2510
+ await mkdir(archivePath, { recursive: true })
2511
+ }
2512
+ await rename(filePath, join(archivePath, filename))
2513
+ } else {
2514
+ // 파일 삭제
2515
+ await unlink(filePath)
2516
+ }
2517
+
2518
+ // DB에 동기화 (삭제된 파일은 archived로 처리됨)
2519
+ const result = await syncBacklogToDb(project.id, project.path)
2520
+
2521
+ emit('backlog:task:deleted', {
2522
+ projectId: project.id,
2523
+ backlogFileId,
2524
+ archived: archive === 'true',
2525
+ })
2526
+
2527
+ res.json({
2528
+ success: true,
2529
+ data: {
2530
+ backlogFileId,
2531
+ deleted: true,
2532
+ archived: archive === 'true',
2533
+ synced: result,
2534
+ },
2535
+ })
2536
+ } catch (error) {
2537
+ console.error('Error deleting backlog task:', error)
2538
+ res.status(500).json({
2539
+ success: false,
2540
+ error: error instanceof Error ? error.message : 'Failed to delete backlog task',
2541
+ })
2542
+ }
2543
+ })
2544
+
2545
+ // GET /backlog/tasks/:backlogFileId - Backlog 태스크 상세 조회
2546
+ flowRouter.get('/backlog/tasks/:backlogFileId', async (req, res) => {
2547
+ try {
2548
+ await initTaskDb()
2549
+ const project = await getActiveProject()
2550
+
2551
+ if (!project) {
2552
+ return res.status(400).json({ success: false, error: 'No active project' })
2553
+ }
2554
+
2555
+ const { backlogFileId } = req.params
2556
+
2557
+ const sqlite = getSqlite()
2558
+ const task = sqlite
2559
+ .prepare(
2560
+ `
2561
+ SELECT * FROM tasks
2562
+ WHERE project_id = ? AND backlog_file_id = ? AND origin = 'backlog'
2563
+ `
2564
+ )
2565
+ .get(project.id, backlogFileId) as
2566
+ | {
2567
+ id: number
2568
+ change_id: string | null
2569
+ stage: Stage
2570
+ origin: TaskOrigin | null
2571
+ title: string
2572
+ description: string | null
2573
+ status: string
2574
+ priority: string
2575
+ tags: string | null
2576
+ assignee: string | null
2577
+ order: number
2578
+ parent_task_id: number | null
2579
+ blocked_by: string | null
2580
+ plan: string | null
2581
+ acceptance_criteria: string | null
2582
+ notes: string | null
2583
+ due_date: number | null
2584
+ milestone: string | null
2585
+ backlog_file_id: string | null
2586
+ created_at: number
2587
+ updated_at: number
2588
+ archived_at: number | null
2589
+ }
2590
+ | undefined
2591
+
2592
+ if (!task) {
2593
+ return res.status(404).json({ success: false, error: 'Backlog task not found' })
2594
+ }
2595
+
2596
+ // 서브태스크 조회
2597
+ const subtasks = sqlite
2598
+ .prepare(
2599
+ `
2600
+ SELECT id, title, status, priority FROM tasks
2601
+ WHERE project_id = ? AND parent_task_id = ? AND origin = 'backlog' AND status != 'archived'
2602
+ `
2603
+ )
2604
+ .all(project.id, task.id) as Array<{
2605
+ id: number
2606
+ title: string
2607
+ status: string
2608
+ priority: string
2609
+ }>
2610
+
2611
+ const formatted = {
2612
+ id: task.id,
2613
+ changeId: task.change_id,
2614
+ stage: task.stage,
2615
+ origin: task.origin,
2616
+ title: task.title,
2617
+ description: task.description,
2618
+ status: task.status,
2619
+ priority: task.priority,
2620
+ tags: task.tags ? JSON.parse(task.tags) : [],
2621
+ assignee: task.assignee,
2622
+ order: task.order,
2623
+ parentTaskId: task.parent_task_id,
2624
+ blockedBy: task.blocked_by ? JSON.parse(task.blocked_by) : null,
2625
+ plan: task.plan,
2626
+ acceptanceCriteria: task.acceptance_criteria,
2627
+ notes: task.notes,
2628
+ dueDate: task.due_date ? new Date(task.due_date).toISOString() : null,
2629
+ milestone: task.milestone,
2630
+ backlogFileId: task.backlog_file_id,
2631
+ subtasks,
2632
+ createdAt: new Date(task.created_at).toISOString(),
2633
+ updatedAt: new Date(task.updated_at).toISOString(),
2634
+ archivedAt: task.archived_at ? new Date(task.archived_at).toISOString() : null,
2635
+ }
2636
+
2637
+ res.json({ success: true, data: { task: formatted } })
2638
+ } catch (error) {
2639
+ console.error('Error getting backlog task:', error)
2640
+ res.status(500).json({ success: false, error: 'Failed to get backlog task' })
2641
+ }
2642
+ })
2643
+
2644
+ // GET /backlog/stats - Backlog 통계
2645
+ flowRouter.get('/backlog/stats', async (_req, res) => {
2646
+ try {
2647
+ await initTaskDb()
2648
+ const project = await getActiveProject()
2649
+
2650
+ if (!project) {
2651
+ return res.status(400).json({ success: false, error: 'No active project' })
2652
+ }
2653
+
2654
+ const sqlite = getSqlite()
2655
+
2656
+ // 상태별 집계
2657
+ const byStatus = sqlite
2658
+ .prepare(
2659
+ `
2660
+ SELECT status, COUNT(*) as count
2661
+ FROM tasks
2662
+ WHERE project_id = ? AND origin = 'backlog' AND status != 'archived'
2663
+ GROUP BY status
2664
+ `
2665
+ )
2666
+ .all(project.id) as Array<{ status: string; count: number }>
2667
+
2668
+ // 우선순위별 집계
2669
+ const byPriority = sqlite
2670
+ .prepare(
2671
+ `
2672
+ SELECT priority, COUNT(*) as count
2673
+ FROM tasks
2674
+ WHERE project_id = ? AND origin = 'backlog' AND status != 'archived'
2675
+ GROUP BY priority
2676
+ `
2677
+ )
2678
+ .all(project.id) as Array<{ priority: string; count: number }>
2679
+
2680
+ // 마일스톤별 집계
2681
+ const byMilestone = sqlite
2682
+ .prepare(
2683
+ `
2684
+ SELECT milestone, COUNT(*) as count
2685
+ FROM tasks
2686
+ WHERE project_id = ? AND origin = 'backlog' AND status != 'archived' AND milestone IS NOT NULL
2687
+ GROUP BY milestone
2688
+ `
2689
+ )
2690
+ .all(project.id) as Array<{ milestone: string; count: number }>
2691
+
2692
+ // 총 태스크 수
2693
+ const total = sqlite
2694
+ .prepare(
2695
+ `
2696
+ SELECT COUNT(*) as count
2697
+ FROM tasks
2698
+ WHERE project_id = ? AND origin = 'backlog' AND status != 'archived'
2699
+ `
2700
+ )
2701
+ .get(project.id) as { count: number }
2702
+
2703
+ res.json({
2704
+ success: true,
2705
+ data: {
2706
+ total: total.count,
2707
+ byStatus: Object.fromEntries(byStatus.map((r) => [r.status, r.count])),
2708
+ byPriority: Object.fromEntries(byPriority.map((r) => [r.priority, r.count])),
2709
+ byMilestone: Object.fromEntries(byMilestone.map((r) => [r.milestone, r.count])),
2710
+ },
2711
+ })
2712
+ } catch (error) {
2713
+ console.error('Error getting backlog stats:', error)
2714
+ res.status(500).json({ success: false, error: 'Failed to get backlog stats' })
2715
+ }
2716
+ })
2717
+
2718
+ // =============================================
2719
+ // Migration API 엔드포인트
2720
+ // =============================================
2721
+
2722
+ // GET /backlog/migration/preview - 마이그레이션 대상 Inbox 태스크 미리보기
2723
+ flowRouter.get('/backlog/migration/preview', async (_req, res) => {
2724
+ try {
2725
+ await initTaskDb()
2726
+ const project = await getActiveProject()
2727
+
2728
+ if (!project) {
2729
+ return res.status(400).json({ success: false, error: 'No active project' })
2730
+ }
2731
+
2732
+ const preview = previewMigration(project.id)
2733
+
2734
+ res.json({
2735
+ success: true,
2736
+ data: preview,
2737
+ })
2738
+ } catch (error) {
2739
+ console.error('Error previewing migration:', error)
2740
+ res.status(500).json({ success: false, error: 'Failed to preview migration' })
2741
+ }
2742
+ })
2743
+
2744
+ // POST /backlog/migration - Inbox 전체를 Backlog로 마이그레이션
2745
+ flowRouter.post('/backlog/migration', async (_req, res) => {
2746
+ try {
2747
+ await initTaskDb()
2748
+ const project = await getActiveProject()
2749
+
2750
+ if (!project) {
2751
+ return res.status(400).json({ success: false, error: 'No active project' })
2752
+ }
2753
+
2754
+ const result = await migrateInboxToBacklog(project.id, project.path)
2755
+
2756
+ // 마이그레이션 성공 시 WebSocket 이벤트 발생
2757
+ if (result.migratedCount > 0) {
2758
+ emit('backlog:synced', {
2759
+ projectId: project.id,
2760
+ synced: result.migratedCount,
2761
+ created: result.migratedCount,
2762
+ updated: 0,
2763
+ deleted: 0,
2764
+ })
2765
+ }
2766
+
2767
+ res.json({
2768
+ success: result.success,
2769
+ data: result,
2770
+ })
2771
+ } catch (error) {
2772
+ console.error('Error migrating inbox to backlog:', error)
2773
+ res.status(500).json({ success: false, error: 'Failed to migrate inbox to backlog' })
2774
+ }
2775
+ })
2776
+
2777
+ // POST /backlog/migration/selected - 선택된 Inbox 태스크만 Backlog로 마이그레이션
2778
+ flowRouter.post('/backlog/migration/selected', async (req, res) => {
2779
+ try {
2780
+ await initTaskDb()
2781
+ const project = await getActiveProject()
2782
+
2783
+ if (!project) {
2784
+ return res.status(400).json({ success: false, error: 'No active project' })
2785
+ }
2786
+
2787
+ const { taskIds } = req.body
2788
+
2789
+ if (!Array.isArray(taskIds) || taskIds.length === 0) {
2790
+ return res.status(400).json({ success: false, error: 'taskIds array is required' })
2791
+ }
2792
+
2793
+ const result = await migrateSelectedInboxTasks(project.id, project.path, taskIds)
2794
+
2795
+ // 마이그레이션 성공 시 WebSocket 이벤트 발생
2796
+ if (result.migratedCount > 0) {
2797
+ emit('backlog:synced', {
2798
+ projectId: project.id,
2799
+ synced: result.migratedCount,
2800
+ created: result.migratedCount,
2801
+ updated: 0,
2802
+ deleted: 0,
2803
+ })
2804
+ }
2805
+
2806
+ res.json({
2807
+ success: result.success,
2808
+ data: result,
2809
+ })
2810
+ } catch (error) {
2811
+ console.error('Error migrating selected tasks:', error)
2812
+ res.status(500).json({ success: false, error: 'Failed to migrate selected tasks' })
2813
+ }
2814
+ })