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
package/server/app.ts ADDED
@@ -0,0 +1,3693 @@
1
+ import express from 'express'
2
+ import cors from 'cors'
3
+ import { readdir, readFile, writeFile, access, mkdir } from 'fs/promises'
4
+ import type { Dirent } from 'fs'
5
+ import { join, basename } from 'path'
6
+ import os from 'os'
7
+ import { exec, spawn } from 'child_process'
8
+ import { promisify } from 'util'
9
+ import { parseTasksFile, toggleTaskInFile } from './parser.js'
10
+ import {
11
+ loadConfig,
12
+ addProject,
13
+ removeProject,
14
+ setActiveProject,
15
+ getActiveProject,
16
+ updateProjectPath,
17
+ updateProjectName,
18
+ reorderProjects,
19
+ } from './config.js'
20
+ import {
21
+ initDb,
22
+ createTask,
23
+ getTask,
24
+ listTasks,
25
+ updateTask,
26
+ deleteTask,
27
+ searchTasks,
28
+ getTasksByStatus,
29
+ archiveTask,
30
+ unarchiveTask,
31
+ TaskStatus,
32
+ TaskPriority,
33
+ } from './tasks/index.js'
34
+ import { gitRouter, gitPull } from './git/index.js'
35
+ import { emit } from './websocket.js'
36
+ import { integrationsRouter, initIntegrationsDb } from './integrations/index.js'
37
+ import { getSqlite } from './tasks/db/client.js'
38
+ import type { Stage, ChangeStatus } from './tasks/db/schema.js'
39
+ import { cliRoutes } from './cli-adapter/index.js'
40
+ import { docsRouter } from './routes/docs.js'
41
+ import { globalChatRouter } from './routes/global-chat.js'
42
+ import { projectsRouter } from './routes/projects.js'
43
+ import { startTasksWatcher, stopTasksWatcher } from './watcher.js'
44
+ import { changesRouter } from './routes/changes.js'
45
+ import { flowRouter } from './routes/flow.js'
46
+ import { alertsRouter, setBroadcastAlert } from './routes/alerts.js'
47
+ import { webhooksRouter, setWebhookBroadcast } from './routes/webhooks.js'
48
+ import { aiRouter } from './ai/index.js'
49
+ import { ragRouter, memoryRouter } from './routes/search.js'
50
+ import { leannRouter } from './routes/leann.js'
51
+ import { scanMoaiSpecs, scanRemoteMoaiSpecs } from './moai-specs.js'
52
+ import { specsRouter } from './routes/specs.js'
53
+ // Remote plugin (optional - only if installed)
54
+ let remoteRouter: import('express').Router | null = null
55
+ let getRemoteServerById: ((id: string) => Promise<import('@zyflow/remote-plugin').RemoteServer | null>) | null = null
56
+ let listDirectory: ((server: import('@zyflow/remote-plugin').RemoteServer, path: string) => Promise<import('@zyflow/remote-plugin').RemoteDirectoryListing>) | null = null
57
+ let readRemoteFile: ((server: import('@zyflow/remote-plugin').RemoteServer, path: string) => Promise<string>) | null = null
58
+ let executeCommand: ((server: import('@zyflow/remote-plugin').RemoteServer, command: string, options?: { cwd?: string; timeout?: number }) => Promise<import('@zyflow/remote-plugin').RemoteCommandResult>) | null = null
59
+
60
+ // Try to load remote plugin
61
+ try {
62
+ const remotePlugin = await import('@zyflow/remote-plugin')
63
+ remoteRouter = remotePlugin.remoteRouter
64
+ getRemoteServerById = remotePlugin.getRemoteServerById
65
+ listDirectory = remotePlugin.listDirectory
66
+ readRemoteFile = remotePlugin.readRemoteFile
67
+ executeCommand = remotePlugin.executeCommand
68
+ console.log('[Remote Plugin] Loaded successfully')
69
+ } catch {
70
+ console.log('[Remote Plugin] Not installed - remote features disabled')
71
+ }
72
+ import { OpenSpecPromptBuilder } from './claude-flow/prompt-builder.js'
73
+ import * as pty from 'node-pty'
74
+ const execAsync = promisify(exec)
75
+
76
+ // Lazy load gitdiagram-core functions to avoid ESM/CJS issues
77
+ let gitdiagramCore: {
78
+ generateFileTree: typeof import('../packages/gitdiagram-core/src/file-tree').generateFileTree
79
+ readReadme: typeof import('../packages/gitdiagram-core/src/file-tree').readReadme
80
+ generateDiagram: typeof import('../packages/gitdiagram-core/src/generator').generateDiagram
81
+ createLLMAdapter: typeof import('../packages/gitdiagram-core/src/llm-adapter').createLLMAdapter
82
+ validateMermaidSyntax: typeof import('../packages/gitdiagram-core/src/mermaid-utils').validateMermaidSyntax
83
+ extractClickEvents: typeof import('../packages/gitdiagram-core/src/mermaid-utils').extractClickEvents
84
+ } | null = null
85
+
86
+ async function getGitdiagramCore() {
87
+ if (!gitdiagramCore) {
88
+ const [fileTree, generator, llmAdapter, mermaidUtils] = await Promise.all([
89
+ import('../packages/gitdiagram-core/src/file-tree.js'),
90
+ import('../packages/gitdiagram-core/src/generator.js'),
91
+ import('../packages/gitdiagram-core/src/llm-adapter.js'),
92
+ import('../packages/gitdiagram-core/src/mermaid-utils.js'),
93
+ ])
94
+ gitdiagramCore = {
95
+ generateFileTree: fileTree.generateFileTree,
96
+ readReadme: fileTree.readReadme,
97
+ generateDiagram: generator.generateDiagram,
98
+ createLLMAdapter: llmAdapter.createLLMAdapter,
99
+ validateMermaidSyntax: mermaidUtils.validateMermaidSyntax,
100
+ extractClickEvents: mermaidUtils.extractClickEvents,
101
+ }
102
+ }
103
+ return gitdiagramCore
104
+ }
105
+
106
+ // Store running Claude processes
107
+ const runningTasks = new Map<
108
+ string,
109
+ {
110
+ process: ReturnType<typeof spawn>
111
+ output: string[]
112
+ status: 'running' | 'completed' | 'error'
113
+ startedAt: Date
114
+ }
115
+ >()
116
+
117
+ export const app = express()
118
+
119
+ // CORS 정책: 허용된 origin만 접근 가능 (보안 강화)
120
+ const allowedOrigins = [
121
+ 'http://localhost:3100', // zyflow-web
122
+ 'http://localhost:3200', // _flow-web
123
+ 'http://127.0.0.1:3100',
124
+ 'http://127.0.0.1:3200',
125
+ ...(process.env.ALLOWED_ORIGINS?.split(',') || []),
126
+ ]
127
+
128
+ app.use(cors({
129
+ origin: (origin, callback) => {
130
+ // Allow requests with no origin (like mobile apps or curl)
131
+ if (!origin) return callback(null, true)
132
+ if (allowedOrigins.includes(origin)) {
133
+ return callback(null, true)
134
+ }
135
+ callback(new Error('CORS policy violation'))
136
+ },
137
+ credentials: true,
138
+ }))
139
+ app.use(express.json())
140
+
141
+ // Git API 라우터 등록
142
+ app.use('/api/git', gitRouter)
143
+
144
+ // Integration Hub API 라우터 등록
145
+ initIntegrationsDb()
146
+ app.use('/api/integrations', integrationsRouter)
147
+
148
+ // CLI Adapter API 라우터 등록
149
+ app.use('/api/cli', cliRoutes)
150
+
151
+
152
+ // AI Execution API 라우터 등록 (단일 Provider 실행)
153
+ app.use('/api/ai', aiRouter)
154
+
155
+ // Docs API 라우터 등록
156
+ app.use('/api/docs', docsRouter)
157
+
158
+ // RAG 검색 API 라우터 등록 (LEANN 연동)
159
+ app.use('/api/rag', ragRouter)
160
+
161
+ // Memory 검색 API 라우터 등록 (claude-mem 연동)
162
+ app.use('/api/memory', memoryRouter)
163
+
164
+ // LEANN 인덱스 관리 API 라우터 등록
165
+ app.use('/api/leann', leannRouter)
166
+
167
+ // Unified SPEC Scanner API 라우터 등록 (SPEC-VISIBILITY-001)
168
+ app.use('/api/specs', specsRouter)
169
+
170
+ // Global Chat API 라우터 등록
171
+ app.use('/api/chat', globalChatRouter)
172
+
173
+ // Projects API 라우터 등록
174
+ app.use('/api/projects', projectsRouter)
175
+
176
+ // Changes API 라우터 등록
177
+ app.use('/api/changes', changesRouter)
178
+
179
+ // Flow API 라우터 등록
180
+ app.use('/api/flow', flowRouter)
181
+
182
+ // Alerts API 라우터 등록
183
+ app.use('/api/alerts', alertsRouter)
184
+
185
+ // Webhooks API 라우터 등록
186
+ app.use('/api/alerts/webhooks', webhooksRouter)
187
+
188
+ // Remote Server API 라우터 등록 (플러그인이 설치된 경우만)
189
+ if (remoteRouter) {
190
+ app.use('/api/remote', remoteRouter)
191
+ }
192
+
193
+ // Alert WebSocket broadcast 설정
194
+ setBroadcastAlert((data) => {
195
+ emit('alert', data)
196
+ })
197
+ setWebhookBroadcast((data) => {
198
+ emit('alert', data)
199
+ })
200
+
201
+ // Health check endpoint
202
+ app.get('/api/health', (_req, res) => {
203
+ res.json({
204
+ success: true,
205
+ data: {
206
+ status: 'ok',
207
+ timestamp: new Date().toISOString(),
208
+ uptime: process.uptime(),
209
+ },
210
+ })
211
+ })
212
+
213
+ // Instance info endpoint (for multi-instance branding)
214
+ app.get('/api/instance', (_req, res) => {
215
+ const instanceName = process.env.INSTANCE_NAME || 'zyflow'
216
+ const displayName = process.env.INSTANCE_DISPLAY_NAME || 'ZyFlow'
217
+ const defaultProjectRoot = process.env.DEFAULT_PROJECT_ROOT || os.homedir()
218
+
219
+ res.json({
220
+ success: true,
221
+ data: {
222
+ name: instanceName,
223
+ displayName: displayName,
224
+ port: process.env.PORT || 3100,
225
+ dataDir: process.env.DATA_DIR || join(os.homedir(), '.zyflow'),
226
+ defaultProjectRoot: defaultProjectRoot.replace(/^~/, os.homedir()),
227
+ },
228
+ })
229
+ })
230
+
231
+ // Helper to get paths for active project
232
+ async function getProjectPaths() {
233
+ const project = await getActiveProject()
234
+ if (!project) {
235
+ return null
236
+ }
237
+ return {
238
+ projectPath: project.path,
239
+ openspecDir: join(project.path, 'openspec', 'changes'),
240
+ specsDir: join(project.path, 'openspec', 'specs'),
241
+ plansDir: join(project.path, '.zyflow', 'plans'),
242
+ // Archive directories (three possible locations)
243
+ archiveDir: join(project.path, 'openspec', 'changes', 'archive'), // openspec/changes/archive/
244
+ legacyArchiveDir: join(project.path, 'openspec', 'archive'), // openspec/archive/
245
+ archivedDir: join(project.path, 'openspec', 'archived'), // openspec/archived/
246
+ }
247
+ }
248
+
249
+ // ==================== PROJECT MANAGEMENT ====================
250
+
251
+ // POST /api/projects/browse - Open native folder picker dialog
252
+ app.post('/api/projects/browse', async (_req, res) => {
253
+ try {
254
+ // macOS: Use AppleScript to open folder picker (simplified version)
255
+ const script = `osascript -e 'POSIX path of (choose folder with prompt "OpenSpec 프로젝트 폴더를 선택하세요")'`
256
+
257
+ const { stdout } = await execAsync(script, { timeout: 120000 }) // 2분 타임아웃
258
+ const selectedPath = stdout.trim().replace(/\/$/, '') // Remove trailing slash
259
+
260
+ if (!selectedPath) {
261
+ return res.json({ success: true, data: { path: null, cancelled: true } })
262
+ }
263
+
264
+ res.json({ success: true, data: { path: selectedPath, cancelled: false } })
265
+ } catch (error) {
266
+ const errorMessage = (error as Error).message || ''
267
+ // User cancelled the dialog (error code -128)
268
+ if (
269
+ errorMessage.includes('-128') ||
270
+ errorMessage.includes('User canceled') ||
271
+ errorMessage.includes('취소')
272
+ ) {
273
+ return res.json({ success: true, data: { path: null, cancelled: true } })
274
+ }
275
+ console.error('Error opening folder picker:', error)
276
+ res.status(500).json({ success: false, error: 'Failed to open folder picker' })
277
+ }
278
+ })
279
+
280
+ // GET /api/projects - List all registered projects
281
+ app.get('/api/projects', async (_req, res) => {
282
+ try {
283
+ const config = await loadConfig()
284
+ res.json({
285
+ success: true,
286
+ data: {
287
+ projects: config.projects,
288
+ activeProjectId: config.activeProjectId,
289
+ },
290
+ })
291
+ } catch (error) {
292
+ console.error('Error listing projects:', error)
293
+ res.status(500).json({ success: false, error: 'Failed to list projects' })
294
+ }
295
+ })
296
+
297
+ // POST /api/projects - Add a new project
298
+ app.post('/api/projects', async (req, res) => {
299
+ try {
300
+ const { path: projectPath } = req.body
301
+
302
+ if (!projectPath) {
303
+ return res.status(400).json({ success: false, error: 'Path is required' })
304
+ }
305
+
306
+ // Check if openspec directory exists
307
+ const openspecPath = join(projectPath, 'openspec')
308
+ try {
309
+ await access(openspecPath)
310
+ } catch {
311
+ return res.status(400).json({
312
+ success: false,
313
+ error: 'No openspec directory found in this project',
314
+ })
315
+ }
316
+
317
+ // Use directory name as project name
318
+ const name = basename(projectPath)
319
+ const project = await addProject(name, projectPath)
320
+
321
+ res.json({ success: true, data: { project } })
322
+ } catch (error) {
323
+ console.error('Error adding project:', error)
324
+ res.status(500).json({ success: false, error: 'Failed to add project' })
325
+ }
326
+ })
327
+
328
+ // PUT /api/projects/reorder - Reorder projects
329
+ // NOTE: This must be defined BEFORE /api/projects/:id routes to avoid :id matching "reorder"
330
+ app.put('/api/projects/reorder', async (req, res) => {
331
+ try {
332
+ const { projectIds } = req.body
333
+
334
+ if (!projectIds || !Array.isArray(projectIds)) {
335
+ return res.status(400).json({ success: false, error: 'projectIds array is required' })
336
+ }
337
+
338
+ const projects = await reorderProjects(projectIds)
339
+ res.json({ success: true, data: { projects } })
340
+ } catch (error) {
341
+ console.error('Error reordering projects:', error)
342
+ res.status(500).json({
343
+ success: false,
344
+ error: error instanceof Error ? error.message : 'Failed to reorder projects',
345
+ })
346
+ }
347
+ })
348
+
349
+ // DELETE /api/projects/:id - Remove a project
350
+ app.delete('/api/projects/:id', async (req, res) => {
351
+ try {
352
+ const projectId = req.params.id
353
+ await removeProject(projectId)
354
+ res.json({ success: true })
355
+ } catch (error) {
356
+ console.error('Error removing project:', error)
357
+ res.status(500).json({ success: false, error: 'Failed to remove project' })
358
+ }
359
+ })
360
+
361
+ // NOTE: /api/projects/:id/activate 핸들러는 routes/projects.ts에서 최적화된 버전으로 제공됩니다.
362
+
363
+ // PUT /api/projects/:id/path - Update project path
364
+ app.put('/api/projects/:id/path', async (req, res) => {
365
+ try {
366
+ const projectId = req.params.id
367
+ const { path: newPath } = req.body
368
+
369
+ if (!newPath) {
370
+ return res.status(400).json({ success: false, error: 'Path is required' })
371
+ }
372
+
373
+ // Check if openspec directory exists in new path
374
+ const openspecPath = join(newPath, 'openspec')
375
+ try {
376
+ await access(openspecPath)
377
+ } catch {
378
+ return res.status(400).json({
379
+ success: false,
380
+ error: 'No openspec directory found in the specified path',
381
+ })
382
+ }
383
+
384
+ const project = await updateProjectPath(projectId, newPath)
385
+
386
+ res.json({ success: true, data: { project } })
387
+ } catch (error) {
388
+ console.error('Error updating project path:', error)
389
+ res.status(500).json({
390
+ success: false,
391
+ error: error instanceof Error ? error.message : 'Failed to update project path',
392
+ })
393
+ }
394
+ })
395
+
396
+ // GET /api/projects/:id/changes - Get changes for a project
397
+ app.get('/api/projects/:id/changes', async (req, res) => {
398
+ try {
399
+ const projectId = req.params.id
400
+ const { getSqlite } = await import('./tasks/db/client.js')
401
+ const sqlite = getSqlite()
402
+
403
+ const changes = sqlite
404
+ .prepare(
405
+ `
406
+ SELECT id, title, status, current_stage, progress, spec_path, created_at, updated_at
407
+ FROM changes
408
+ WHERE project_id = ?
409
+ ORDER BY updated_at DESC
410
+ `
411
+ )
412
+ .all(projectId)
413
+
414
+ res.json({ success: true, changes })
415
+ } catch (error) {
416
+ console.error('Error fetching project changes:', error)
417
+ res.status(500).json({
418
+ success: false,
419
+ error: error instanceof Error ? error.message : 'Failed to fetch changes',
420
+ })
421
+ }
422
+ })
423
+
424
+ // PUT /api/projects/:id/name - Update project name
425
+ app.put('/api/projects/:id/name', async (req, res) => {
426
+ try {
427
+ const projectId = req.params.id
428
+ const { name: newName } = req.body
429
+
430
+ if (!newName || typeof newName !== 'string') {
431
+ return res.status(400).json({ success: false, error: 'Name is required' })
432
+ }
433
+
434
+ const trimmedName = newName.trim()
435
+ if (trimmedName.length === 0) {
436
+ return res.status(400).json({ success: false, error: 'Name cannot be empty' })
437
+ }
438
+
439
+ const project = await updateProjectName(projectId, trimmedName)
440
+ res.json({ success: true, data: { project } })
441
+ } catch (error) {
442
+ console.error('Error updating project name:', error)
443
+ res.status(500).json({
444
+ success: false,
445
+ error: error instanceof Error ? error.message : 'Failed to update project name',
446
+ })
447
+ }
448
+ })
449
+
450
+ // ==================== ALL PROJECTS DATA ====================
451
+
452
+ // Helper to parse affected specs from proposal content
453
+ function parseAffectedSpecs(proposalContent: string): string[] {
454
+ const specs: string[] = []
455
+
456
+ // Find ### Affected Specs section
457
+ const affectedSpecsMatch = proposalContent.match(
458
+ /###\s*Affected Specs\s*\n([\s\S]*?)(?=\n###|\n##|$)/i
459
+ )
460
+ if (!affectedSpecsMatch) return specs
461
+
462
+ const section = affectedSpecsMatch[1]
463
+ // Match patterns like: - **NEW**: `spec-name` or - **MODIFIED**: `spec-name`
464
+ const specMatches = section.matchAll(/`([^`]+)`/g)
465
+ for (const match of specMatches) {
466
+ specs.push(match[1])
467
+ }
468
+
469
+ return specs
470
+ }
471
+
472
+ // Helper to get changes for a specific project (supports both local and remote)
473
+ async function getChangesForProject(project: {
474
+ id: string
475
+ path: string
476
+ remote?: { serverId: string }
477
+ }) {
478
+ const projectPath = project.path
479
+
480
+ // Get archived change IDs from DB to filter them out
481
+ const archivedChangeIds = new Set<string>()
482
+ try {
483
+ const sqlite = getSqlite()
484
+ const archivedRows = sqlite
485
+ .prepare(
486
+ `
487
+ SELECT id FROM changes WHERE project_id = ? AND status = 'archived'
488
+ `
489
+ )
490
+ .all(project.id) as { id: string }[]
491
+ for (const row of archivedRows) {
492
+ archivedChangeIds.add(row.id)
493
+ }
494
+ } catch {
495
+ // DB not initialized yet, proceed without filtering
496
+ }
497
+
498
+ // 원격 프로젝트인 경우 (플러그인이 설치된 경우만)
499
+ if (project.remote) {
500
+ if (!getRemoteServerById || !listDirectory || !readRemoteFile || !executeCommand) {
501
+ console.log('[Remote] Plugin not installed - cannot access remote project')
502
+ return []
503
+ }
504
+
505
+ const server = await getRemoteServerById(project.remote.serverId)
506
+ if (!server) {
507
+ return []
508
+ }
509
+
510
+ const openspecDir = `${projectPath}/openspec/changes`
511
+
512
+ // 원격 디렉토리 목록 조회
513
+ let listing
514
+ try {
515
+ listing = await listDirectory(server, openspecDir)
516
+ } catch {
517
+ return []
518
+ }
519
+
520
+ // 디렉토리만 필터링 (archive 제외)
521
+ const validEntries = listing.entries.filter(
522
+ (entry) => entry.type === 'directory' && entry.name !== 'archive'
523
+ )
524
+
525
+ // 병렬로 각 change 처리
526
+ const changes = await Promise.all(
527
+ validEntries.map(async (entry) => {
528
+ const changeId = entry.name
529
+
530
+ // Skip archived changes (based on DB status)
531
+ if (archivedChangeIds.has(changeId)) return null
532
+
533
+ const changeDir = `${openspecDir}/${changeId}`
534
+
535
+ // 원격에서 proposal.md, tasks.md 읽기 및 git log 실행
536
+ const [proposalResult, tasksResult, gitResult] = await Promise.allSettled([
537
+ readRemoteFile(server, `${changeDir}/proposal.md`),
538
+ readRemoteFile(server, `${changeDir}/tasks.md`),
539
+ executeCommand(server, `git log -1 --format="%aI" -- "openspec/changes/${changeId}"`, {
540
+ cwd: projectPath,
541
+ }),
542
+ ])
543
+
544
+ // Parse proposal
545
+ let title = changeId
546
+ let relatedSpecs: string[] = []
547
+ if (proposalResult.status === 'fulfilled') {
548
+ const titleMatch = proposalResult.value.match(/^#\s+(?:Change:\s+)?(.+)$/m)
549
+ if (titleMatch) title = titleMatch[1].trim()
550
+ relatedSpecs = parseAffectedSpecs(proposalResult.value)
551
+ }
552
+
553
+ // Parse tasks
554
+ let totalTasks = 0
555
+ let completedTasks = 0
556
+ if (tasksResult.status === 'fulfilled') {
557
+ const parsed = parseTasksFile(changeId, tasksResult.value)
558
+ for (const group of parsed.groups) {
559
+ totalTasks += group.tasks.length
560
+ completedTasks += group.tasks.filter((t) => t.completed).length
561
+ }
562
+ }
563
+
564
+ const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
565
+ const status = progress === 100 ? 'completed' : 'active'
566
+
567
+ // Get updatedAt from git log or file modifiedAt
568
+ let updatedAt: string | null = null
569
+ if (gitResult.status === 'fulfilled') {
570
+ const stdout = gitResult.value.stdout.trim()
571
+ if (stdout) {
572
+ updatedAt = stdout
573
+ } else {
574
+ // git log 결과가 비어있으면 modifiedAt 사용
575
+ updatedAt = entry.modifiedAt || new Date().toISOString()
576
+ }
577
+ } else {
578
+ // git log 실패 시 modifiedAt 사용
579
+ updatedAt = entry.modifiedAt || new Date().toISOString()
580
+ }
581
+
582
+ return {
583
+ id: changeId,
584
+ title,
585
+ progress,
586
+ totalTasks,
587
+ completedTasks,
588
+ relatedSpecs,
589
+ updatedAt,
590
+ type: 'openspec' as const,
591
+ status,
592
+ }
593
+ })
594
+ )
595
+
596
+ const openspecChanges = changes.filter((c): c is NonNullable<typeof c> => c !== null)
597
+
598
+ // 2. 원격 MoAI SPECs 스캔 (.moai/specs/)
599
+ const moaiSpecChanges: typeof openspecChanges = []
600
+ try {
601
+ const plugin = {
602
+ listDirectory: listDirectory!,
603
+ readRemoteFile: readRemoteFile!,
604
+ }
605
+ const moaiSpecs = await scanRemoteMoaiSpecs(projectPath, server, plugin)
606
+ for (const spec of moaiSpecs) {
607
+ // Skip archived SPECs
608
+ if (archivedChangeIds.has(spec.id) || spec.status === 'archived') continue
609
+
610
+ const progress = spec.tagCount > 0
611
+ ? Math.round((spec.completedTags / spec.tagCount) * 100)
612
+ : 0
613
+
614
+ moaiSpecChanges.push({
615
+ id: spec.id,
616
+ title: spec.title,
617
+ progress,
618
+ totalTasks: spec.tagCount,
619
+ completedTasks: spec.completedTags,
620
+ updatedAt: new Date().toISOString(),
621
+ type: 'spec' as const,
622
+ status: spec.status === 'complete' ? 'completed' : spec.status,
623
+ })
624
+ }
625
+ } catch (err) {
626
+ console.warn('[Remote] Failed to scan remote MoAI SPECs:', err)
627
+ }
628
+
629
+ return [...moaiSpecChanges, ...openspecChanges]
630
+ }
631
+
632
+ // 로컬 프로젝트인 경우
633
+ const changes: Array<{
634
+ id: string
635
+ title: string
636
+ progress: number
637
+ totalTasks: number
638
+ completedTasks: number
639
+ relatedSpecs?: string[]
640
+ updatedAt: string | null
641
+ type?: 'openspec' | 'spec'
642
+ }> = []
643
+
644
+ // 1. MoAI SPECs 스캔 (.moai/specs/)
645
+ try {
646
+ const moaiSpecs = await scanMoaiSpecs(projectPath)
647
+ for (const spec of moaiSpecs) {
648
+ // Skip archived SPECs
649
+ if (archivedChangeIds.has(spec.id) || spec.status === 'archived') continue
650
+
651
+ const progress = spec.tagCount > 0
652
+ ? Math.round((spec.completedTags / spec.tagCount) * 100)
653
+ : 0
654
+
655
+ // Get updatedAt from git or file system
656
+ let updatedAt: string | null = null
657
+ try {
658
+ const gitResult = await execAsync(
659
+ `git log -1 --format="%aI" -- ".moai/specs/${spec.id}"`,
660
+ { cwd: projectPath }
661
+ )
662
+ updatedAt = gitResult.stdout.trim() || new Date().toISOString()
663
+ } catch {
664
+ updatedAt = new Date().toISOString()
665
+ }
666
+
667
+ changes.push({
668
+ id: spec.id,
669
+ title: spec.title,
670
+ progress,
671
+ totalTasks: spec.tagCount,
672
+ completedTasks: spec.completedTags,
673
+ updatedAt,
674
+ type: 'spec',
675
+ status: spec.status === 'complete' ? 'completed' : spec.status,
676
+ })
677
+ }
678
+ } catch {
679
+ // .moai/specs/ scan failed, continue with OpenSpec
680
+ }
681
+
682
+ // 2. OpenSpec changes 스캔 (openspec/changes/)
683
+ const openspecDir = join(projectPath, 'openspec', 'changes')
684
+
685
+ let entries: Dirent[] = []
686
+ try {
687
+ entries = await readdir(openspecDir, { withFileTypes: true })
688
+ } catch {
689
+ // openspec/changes/ does not exist, return MoAI SPECs only
690
+ return changes
691
+ }
692
+
693
+ for (const entry of entries) {
694
+ if (!entry.isDirectory() || entry.name === 'archive') continue
695
+
696
+ const changeId = entry.name
697
+
698
+ // Skip archived changes (based on DB status)
699
+ if (archivedChangeIds.has(changeId)) continue
700
+ const changeDir = join(openspecDir, changeId)
701
+
702
+ let title = changeId
703
+ let relatedSpecs: string[] = []
704
+ try {
705
+ const proposalPath = join(changeDir, 'proposal.md')
706
+ const proposalContent = await readFile(proposalPath, 'utf-8')
707
+ // Try to match "# Change: ..." or just first "# ..." heading
708
+ const titleMatch = proposalContent.match(/^#\s+(?:Change:\s+)?(.+)$/m)
709
+ if (titleMatch) {
710
+ title = titleMatch[1].trim()
711
+ }
712
+ // Parse affected specs
713
+ relatedSpecs = parseAffectedSpecs(proposalContent)
714
+ } catch {
715
+ // proposal.md not found
716
+ }
717
+
718
+ let totalTasks = 0
719
+ let completedTasks = 0
720
+ try {
721
+ const tasksPath = join(changeDir, 'tasks.md')
722
+ const tasksContent = await readFile(tasksPath, 'utf-8')
723
+ const parsed = parseTasksFile(changeId, tasksContent)
724
+ for (const group of parsed.groups) {
725
+ totalTasks += group.tasks.length
726
+ completedTasks += group.tasks.filter((t) => t.completed).length
727
+ }
728
+ } catch {
729
+ // tasks.md not found
730
+ }
731
+
732
+ // Get last modified date from git
733
+ let updatedAt: string | null = null
734
+ try {
735
+ const relativeChangeDir = `openspec/changes/${changeId}`
736
+ const gitCmd = `git log -1 --format="%aI" -- "${relativeChangeDir}"`
737
+ const { stdout } = await execAsync(gitCmd, { cwd: projectPath })
738
+ if (stdout.trim()) {
739
+ updatedAt = stdout.trim()
740
+ } else {
741
+ // Fallback to file stat
742
+ const tasksPath = join(changeDir, 'tasks.md')
743
+ const stat = await import('fs/promises').then((fs) => fs.stat(tasksPath))
744
+ updatedAt = stat.mtime.toISOString()
745
+ }
746
+ } catch {
747
+ updatedAt = new Date().toISOString()
748
+ }
749
+
750
+ const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
751
+ const status = progress === 100 ? 'completed' : 'active'
752
+ changes.push({
753
+ id: changeId,
754
+ title,
755
+ progress,
756
+ totalTasks,
757
+ completedTasks,
758
+ relatedSpecs,
759
+ updatedAt,
760
+ type: 'openspec',
761
+ status,
762
+ })
763
+ }
764
+
765
+ return changes
766
+ }
767
+
768
+ // Helper to get specs for a specific project path
769
+ async function getSpecsForProject(projectPath: string) {
770
+ const specsDir = join(projectPath, 'openspec', 'specs')
771
+ const specs = []
772
+
773
+ let entries
774
+ try {
775
+ entries = await readdir(specsDir, { withFileTypes: true })
776
+ } catch {
777
+ return []
778
+ }
779
+
780
+ for (const entry of entries) {
781
+ if (!entry.isDirectory()) continue
782
+
783
+ const specId = entry.name
784
+ const specDir = join(specsDir, specId)
785
+
786
+ let title = specId
787
+ let requirementsCount = 0
788
+ try {
789
+ const specPath = join(specDir, 'spec.md')
790
+ const specContent = await readFile(specPath, 'utf-8')
791
+ const titleMatch = specContent.match(/^#\s+(.+)$/m)
792
+ if (titleMatch) {
793
+ title = titleMatch[1].trim()
794
+ }
795
+ const reqMatches = specContent.match(/^###\s+Requirement:/gm)
796
+ requirementsCount = reqMatches ? reqMatches.length : 0
797
+ } catch {
798
+ // spec.md not found
799
+ }
800
+
801
+ specs.push({ id: specId, title, requirementsCount })
802
+ }
803
+
804
+ return specs
805
+ }
806
+
807
+ // GET /api/projects/all-data - Get all projects with their changes and specs
808
+ app.get('/api/projects/all-data', async (_req, res) => {
809
+ try {
810
+ const config = await loadConfig()
811
+ const projectsData = []
812
+
813
+ for (const project of config.projects) {
814
+ const changes = await getChangesForProject(project)
815
+ const specs = await getSpecsForProject(project.path)
816
+ projectsData.push({
817
+ ...project,
818
+ changes,
819
+ specs,
820
+ })
821
+ }
822
+
823
+ res.json({
824
+ success: true,
825
+ data: {
826
+ projects: projectsData,
827
+ activeProjectId: config.activeProjectId,
828
+ },
829
+ })
830
+ } catch (error) {
831
+ console.error('Error getting all projects data:', error)
832
+ res.status(500).json({ success: false, error: 'Failed to get projects data' })
833
+ }
834
+ })
835
+
836
+ // ==================== CHANGES ====================
837
+
838
+ // GET /api/changes - List all changes
839
+ app.get('/api/changes', async (_req, res) => {
840
+ try {
841
+ const paths = await getProjectPaths()
842
+ if (!paths) {
843
+ return res.json({ success: true, data: { changes: [] } })
844
+ }
845
+
846
+ let entries
847
+ try {
848
+ entries = await readdir(paths.openspecDir, { withFileTypes: true })
849
+ } catch {
850
+ return res.json({ success: true, data: { changes: [] } })
851
+ }
852
+
853
+ const changes = []
854
+
855
+ for (const entry of entries) {
856
+ if (!entry.isDirectory() || entry.name === 'archive') continue
857
+
858
+ const changeId = entry.name
859
+ const changeDir = join(paths.openspecDir, changeId)
860
+
861
+ // Read proposal.md for title
862
+ let title = changeId
863
+ try {
864
+ const proposalPath = join(changeDir, 'proposal.md')
865
+ const proposalContent = await readFile(proposalPath, 'utf-8')
866
+ const titleMatch = proposalContent.match(/^#\s+Change:\s+(.+)$/m)
867
+ if (titleMatch) {
868
+ title = titleMatch[1].trim()
869
+ }
870
+ } catch {
871
+ // No proposal.md, use directory name
872
+ }
873
+
874
+ // Read tasks.md for progress
875
+ let totalTasks = 0
876
+ let completedTasks = 0
877
+ try {
878
+ const tasksPath = join(changeDir, 'tasks.md')
879
+ const tasksContent = await readFile(tasksPath, 'utf-8')
880
+ const parsed = parseTasksFile(changeId, tasksContent)
881
+
882
+ for (const group of parsed.groups) {
883
+ totalTasks += group.tasks.length
884
+ completedTasks += group.tasks.filter((t) => t.completed).length
885
+ }
886
+ } catch {
887
+ // No tasks.md
888
+ }
889
+
890
+ const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
891
+
892
+ // Get last modified date from git (latest commit in change directory)
893
+ let updatedAt: string | null = null
894
+ try {
895
+ // Get latest commit date for any file in the change directory
896
+ // Use relative path from project root for git command
897
+ const relativeChangeDir = `openspec/changes/${changeId}`
898
+ const gitCmd = `git log -1 --format="%aI" -- "${relativeChangeDir}"`
899
+ const { stdout } = await execAsync(gitCmd, { cwd: paths.projectPath })
900
+ if (stdout.trim()) {
901
+ updatedAt = stdout.trim()
902
+ } else {
903
+ // Fallback to file stat of tasks.md or proposal.md
904
+ const tasksPath = join(changeDir, 'tasks.md')
905
+ const proposalPath = join(changeDir, 'proposal.md')
906
+ try {
907
+ const stat = await import('fs/promises').then((fs) => fs.stat(tasksPath))
908
+ updatedAt = stat.mtime.toISOString()
909
+ } catch {
910
+ const stat = await import('fs/promises').then((fs) => fs.stat(proposalPath))
911
+ updatedAt = stat.mtime.toISOString()
912
+ }
913
+ }
914
+ } catch (err) {
915
+ // If all fails, use current time
916
+ console.error('Git log error:', err)
917
+ updatedAt = new Date().toISOString()
918
+ }
919
+
920
+ changes.push({
921
+ id: changeId,
922
+ title,
923
+ progress,
924
+ totalTasks,
925
+ completedTasks,
926
+ updatedAt,
927
+ })
928
+ }
929
+
930
+ res.json({ success: true, data: { changes } })
931
+ } catch (error) {
932
+ console.error('Error listing changes:', error)
933
+ res.status(500).json({ success: false, error: 'Failed to list changes' })
934
+ }
935
+ })
936
+
937
+ // GET /api/changes/archived - List all archived changes
938
+ app.get('/api/changes/archived', async (_req, res) => {
939
+ try {
940
+ const paths = await getProjectPaths()
941
+ if (!paths) {
942
+ return res.json({ success: true, data: { changes: [] } })
943
+ }
944
+
945
+ const archivedChanges: Array<{
946
+ id: string
947
+ title: string
948
+ progress: number
949
+ totalTasks: number
950
+ completedTasks: number
951
+ archivedAt: string | null
952
+ source: 'archive' | 'archived'
953
+ }> = []
954
+
955
+ // Helper function to read archived change from a directory
956
+ async function readArchivedChange(
957
+ changeDir: string,
958
+ changeId: string,
959
+ source: 'archive' | 'archived'
960
+ ) {
961
+ // Read proposal.md for title
962
+ let title = changeId
963
+ try {
964
+ const proposalPath = join(changeDir, 'proposal.md')
965
+ const proposalContent = await readFile(proposalPath, 'utf-8')
966
+ const titleMatch = proposalContent.match(/^#\s+Change:\s+(.+)$/m)
967
+ if (titleMatch) {
968
+ title = titleMatch[1].trim()
969
+ }
970
+ } catch {
971
+ // No proposal.md, use directory name
972
+ }
973
+
974
+ // Read tasks.md for progress
975
+ let totalTasks = 0
976
+ let completedTasks = 0
977
+ try {
978
+ const tasksPath = join(changeDir, 'tasks.md')
979
+ const tasksContent = await readFile(tasksPath, 'utf-8')
980
+ const parsed = parseTasksFile(changeId, tasksContent)
981
+
982
+ for (const group of parsed.groups) {
983
+ totalTasks += group.tasks.length
984
+ completedTasks += group.tasks.filter((t) => t.completed).length
985
+ }
986
+ } catch {
987
+ // No tasks.md
988
+ }
989
+
990
+ const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
991
+
992
+ // Get archived date from git or file stat
993
+ let archivedAt: string | null = null
994
+ try {
995
+ const { stat } = await import('fs/promises')
996
+ const proposalPath = join(changeDir, 'proposal.md')
997
+ const tasksPath = join(changeDir, 'tasks.md')
998
+ try {
999
+ const s = await stat(proposalPath)
1000
+ archivedAt = s.mtime.toISOString()
1001
+ } catch {
1002
+ const s = await stat(tasksPath)
1003
+ archivedAt = s.mtime.toISOString()
1004
+ }
1005
+ } catch {
1006
+ archivedAt = null
1007
+ }
1008
+
1009
+ return {
1010
+ id: changeId,
1011
+ title,
1012
+ progress,
1013
+ totalTasks,
1014
+ completedTasks,
1015
+ archivedAt,
1016
+ source,
1017
+ }
1018
+ }
1019
+
1020
+ // Helper to read all changes from an archive directory
1021
+ async function readFromArchiveDir(dir: string) {
1022
+ try {
1023
+ const entries = await readdir(dir, { withFileTypes: true })
1024
+ for (const entry of entries) {
1025
+ if (!entry.isDirectory()) continue
1026
+ const changeDir = join(dir, entry.name)
1027
+ const change = await readArchivedChange(changeDir, entry.name, 'archive')
1028
+ archivedChanges.push(change)
1029
+ }
1030
+ } catch {
1031
+ // Directory doesn't exist
1032
+ }
1033
+ }
1034
+
1035
+ // Read from all three archive locations
1036
+ await readFromArchiveDir(paths.archiveDir) // openspec/changes/archive/
1037
+ await readFromArchiveDir(paths.legacyArchiveDir) // openspec/archive/
1038
+ await readFromArchiveDir(paths.archivedDir) // openspec/archived/
1039
+
1040
+ // Sort by archivedAt descending (newest first)
1041
+ archivedChanges.sort((a, b) => {
1042
+ if (!a.archivedAt && !b.archivedAt) return 0
1043
+ if (!a.archivedAt) return 1
1044
+ if (!b.archivedAt) return -1
1045
+ return new Date(b.archivedAt).getTime() - new Date(a.archivedAt).getTime()
1046
+ })
1047
+
1048
+ res.json({ success: true, data: { changes: archivedChanges } })
1049
+ } catch (error) {
1050
+ console.error('Error listing archived changes:', error)
1051
+ res.status(500).json({ success: false, error: 'Failed to list archived changes' })
1052
+ }
1053
+ })
1054
+
1055
+ // GET /api/changes/archived/:id - Get archived change detail
1056
+ app.get('/api/changes/archived/:id', async (req, res) => {
1057
+ try {
1058
+ const project = await getActiveProject()
1059
+ if (!project) {
1060
+ return res.status(400).json({ success: false, error: 'No active project' })
1061
+ }
1062
+
1063
+ const changeId = req.params.id
1064
+ const files: Record<string, string> = {}
1065
+
1066
+ // Remote project: use SSH plugin
1067
+ if (project.remote) {
1068
+ if (!getRemoteServerById || !listDirectory || !readRemoteFile) {
1069
+ return res.status(400).json({ success: false, error: 'Remote plugin not available' })
1070
+ }
1071
+
1072
+ const server = await getRemoteServerById(project.remote.serverId)
1073
+ if (!server) {
1074
+ return res.status(400).json({ success: false, error: 'Remote server not found' })
1075
+ }
1076
+
1077
+ // Archive locations for remote projects
1078
+ const archiveLocations = [
1079
+ `${project.path}/openspec/changes/archive`,
1080
+ `${project.path}/openspec/archive`,
1081
+ `${project.path}/openspec/archived`,
1082
+ ]
1083
+
1084
+ let changeDir: string | null = null
1085
+
1086
+ // Search for the change folder in archive locations
1087
+ for (const archiveBase of archiveLocations) {
1088
+ try {
1089
+ const listing = await listDirectory(server, archiveBase)
1090
+ // Look for exact match or date-prefixed match (e.g., 2026-01-17-change-id)
1091
+ const matchingFolder = listing.entries.find(
1092
+ (entry) => entry.type === 'directory' && (entry.name === changeId || entry.name.endsWith(`-${changeId}`))
1093
+ )
1094
+ if (matchingFolder) {
1095
+ changeDir = `${archiveBase}/${matchingFolder.name}`
1096
+ break
1097
+ }
1098
+ } catch {
1099
+ // Archive location not found, try next
1100
+ }
1101
+ }
1102
+
1103
+ if (!changeDir) {
1104
+ return res.status(404).json({ success: false, error: 'Archived change not found' })
1105
+ }
1106
+
1107
+ // Read all .md files in the change directory
1108
+ try {
1109
+ const entries = await listDirectory(server, changeDir)
1110
+ for (const entry of entries.entries) {
1111
+ if (entry.type === 'file' && entry.name.endsWith('.md')) {
1112
+ try {
1113
+ const content = await readRemoteFile(server, `${changeDir}/${entry.name}`)
1114
+ files[entry.name] = content
1115
+ } catch {
1116
+ // Skip unreadable files
1117
+ }
1118
+ }
1119
+ // Also check specs subdirectory
1120
+ if (entry.type === 'directory' && entry.name === 'specs') {
1121
+ try {
1122
+ const specsDir = `${changeDir}/specs`
1123
+ const specEntries = await listDirectory(server, specsDir)
1124
+ for (const specEntry of specEntries.entries) {
1125
+ if (specEntry.type === 'directory') {
1126
+ // Each spec is in its own folder
1127
+ try {
1128
+ const specPath = `${specsDir}/${specEntry.name}/spec.md`
1129
+ const content = await readRemoteFile(server, specPath)
1130
+ files[`specs/${specEntry.name}/spec.md`] = content
1131
+ } catch {
1132
+ // Skip unreadable spec files
1133
+ }
1134
+ }
1135
+ }
1136
+ } catch {
1137
+ // Skip if specs dir not accessible
1138
+ }
1139
+ }
1140
+ }
1141
+ } catch (error) {
1142
+ console.error('Error reading remote archived change:', error)
1143
+ return res.status(500).json({ success: false, error: 'Failed to read archived change' })
1144
+ }
1145
+
1146
+ return res.json({
1147
+ success: true,
1148
+ data: {
1149
+ id: changeId,
1150
+ files,
1151
+ },
1152
+ })
1153
+ }
1154
+
1155
+ // Local project: use filesystem
1156
+ const archiveLocations = [
1157
+ join(project.path, 'openspec', 'changes', 'archive'),
1158
+ join(project.path, 'openspec', 'archive'),
1159
+ join(project.path, 'openspec', 'archived'),
1160
+ ]
1161
+
1162
+ let changeDir: string | null = null
1163
+
1164
+ for (const archiveBase of archiveLocations) {
1165
+ const candidatePath = join(archiveBase, changeId)
1166
+ try {
1167
+ await access(candidatePath)
1168
+ changeDir = candidatePath
1169
+ break
1170
+ } catch {
1171
+ // Not found in this location, try next
1172
+ }
1173
+ }
1174
+
1175
+ if (!changeDir) {
1176
+ return res.status(404).json({ success: false, error: 'Archived change not found' })
1177
+ }
1178
+
1179
+ // Read all files in the change directory
1180
+ const entries = await readdir(changeDir, { withFileTypes: true })
1181
+
1182
+ for (const entry of entries) {
1183
+ if (entry.isFile() && entry.name.endsWith('.md')) {
1184
+ const filePath = join(changeDir, entry.name)
1185
+ const content = await readFile(filePath, 'utf-8')
1186
+ files[entry.name] = content
1187
+ }
1188
+ // Also check specs subdirectory
1189
+ if (entry.isDirectory() && entry.name === 'specs') {
1190
+ const specsDir = join(changeDir, 'specs')
1191
+ const specEntries = await readdir(specsDir, { withFileTypes: true })
1192
+ for (const specEntry of specEntries) {
1193
+ if (specEntry.isFile() && specEntry.name.endsWith('.md')) {
1194
+ const specPath = join(specsDir, specEntry.name)
1195
+ const content = await readFile(specPath, 'utf-8')
1196
+ files[`specs/${specEntry.name}`] = content
1197
+ }
1198
+ }
1199
+ }
1200
+ }
1201
+
1202
+ res.json({
1203
+ success: true,
1204
+ data: {
1205
+ id: changeId,
1206
+ files,
1207
+ },
1208
+ })
1209
+ } catch (error) {
1210
+ console.error('Error getting archived change:', error)
1211
+ res.status(500).json({ success: false, error: 'Failed to get archived change' })
1212
+ }
1213
+ })
1214
+
1215
+ // GET /api/changes/:id/tasks - Get tasks for a change
1216
+ app.get('/api/changes/:id/tasks', async (req, res) => {
1217
+ try {
1218
+ const paths = await getProjectPaths()
1219
+ if (!paths) {
1220
+ return res.status(400).json({ success: false, error: 'No active project' })
1221
+ }
1222
+
1223
+ const changeId = req.params.id
1224
+ const tasksPath = join(paths.openspecDir, changeId, 'tasks.md')
1225
+ const content = await readFile(tasksPath, 'utf-8')
1226
+ const parsed = parseTasksFile(changeId, content)
1227
+
1228
+ res.json({ success: true, data: parsed })
1229
+ } catch (error) {
1230
+ console.error('Error reading tasks:', error)
1231
+ res.status(500).json({ success: false, error: 'Failed to read tasks' })
1232
+ }
1233
+ })
1234
+
1235
+ // PATCH /api/tasks/:changeId/:taskId - Toggle task checkbox
1236
+ app.patch('/api/tasks/:changeId/:taskId', async (req, res) => {
1237
+ try {
1238
+ const paths = await getProjectPaths()
1239
+ if (!paths) {
1240
+ return res.status(400).json({ success: false, error: 'No active project' })
1241
+ }
1242
+
1243
+ const { changeId, taskId } = req.params
1244
+ const tasksPath = join(paths.openspecDir, changeId, 'tasks.md')
1245
+
1246
+ // Read current content
1247
+ const content = await readFile(tasksPath, 'utf-8')
1248
+
1249
+ // Toggle the task
1250
+ const { newContent, task } = toggleTaskInFile(content, taskId)
1251
+
1252
+ // Write back
1253
+ await writeFile(tasksPath, newContent, 'utf-8')
1254
+
1255
+ res.json({ success: true, data: { task } })
1256
+ } catch (error) {
1257
+ console.error('Error toggling task:', error)
1258
+ res.status(500).json({ success: false, error: 'Failed to toggle task' })
1259
+ }
1260
+ })
1261
+
1262
+ // GET /api/plans/:changeId/:taskId - Get detail plan
1263
+ app.get('/api/plans/:changeId/:taskId', async (req, res) => {
1264
+ try {
1265
+ const paths = await getProjectPaths()
1266
+ if (!paths) {
1267
+ return res.status(400).json({ success: false, error: 'No active project' })
1268
+ }
1269
+
1270
+ const { changeId, taskId } = req.params
1271
+ const planPath = join(paths.plansDir, changeId, `${taskId}.md`)
1272
+
1273
+ try {
1274
+ const content = await readFile(planPath, 'utf-8')
1275
+ res.json({
1276
+ success: true,
1277
+ data: { taskId, changeId, content, exists: true },
1278
+ })
1279
+ } catch {
1280
+ res.json({
1281
+ success: true,
1282
+ data: { taskId, changeId, content: '', exists: false },
1283
+ })
1284
+ }
1285
+ } catch (error) {
1286
+ console.error('Error reading plan:', error)
1287
+ res.status(500).json({ success: false, error: 'Failed to read plan' })
1288
+ }
1289
+ })
1290
+
1291
+ // ==================== SPECS ====================
1292
+ // NOTE: Legacy /api/specs routes replaced by unified specsRouter (SPEC-VISIBILITY-001)
1293
+ // See server/routes/specs.ts for new implementation that supports both MoAI and OpenSpec formats
1294
+
1295
+ // PATCH /api/tasks/reorder - Reorder tasks within a group
1296
+ app.patch('/api/tasks/reorder', async (req, res) => {
1297
+ try {
1298
+ const paths = await getProjectPaths()
1299
+ if (!paths) {
1300
+ return res.status(400).json({ success: false, error: 'No active project' })
1301
+ }
1302
+
1303
+ const { changeId, groupId, taskIds } = req.body
1304
+ const tasksPath = join(paths.openspecDir, changeId, 'tasks.md')
1305
+
1306
+ const content = await readFile(tasksPath, 'utf-8')
1307
+ const lines = content.split('\n')
1308
+
1309
+ // Find the group and its tasks
1310
+ const groupNumber = groupId.replace('group-', '')
1311
+ let inGroup = false
1312
+ let groupStartLine = -1
1313
+ let groupEndLine = -1
1314
+ const taskLines: { id: string; line: string; lineNum: number }[] = []
1315
+
1316
+ for (let i = 0; i < lines.length; i++) {
1317
+ const line = lines[i]
1318
+ const groupMatch = line.match(/^##\s+(\d+)\.\s+/)
1319
+
1320
+ if (groupMatch) {
1321
+ if (groupMatch[1] === groupNumber) {
1322
+ inGroup = true
1323
+ groupStartLine = i
1324
+ } else if (inGroup) {
1325
+ groupEndLine = i
1326
+ break
1327
+ }
1328
+ }
1329
+
1330
+ if (inGroup) {
1331
+ const taskMatch = line.match(/^-\s+\[[ xX]\]\s+(\d+\.\d+)\s+/)
1332
+ if (taskMatch) {
1333
+ const taskId = `task-${taskMatch[1].replace('.', '-')}`
1334
+ taskLines.push({ id: taskId, line, lineNum: i })
1335
+ }
1336
+ }
1337
+ }
1338
+
1339
+ if (groupEndLine === -1) groupEndLine = lines.length
1340
+
1341
+ // Reorder task lines based on taskIds
1342
+ const reorderedTaskLines = taskIds
1343
+ .map((id: string) => {
1344
+ const found = taskLines.find((t) => t.id === id)
1345
+ return found ? found.line : null
1346
+ })
1347
+ .filter(Boolean)
1348
+
1349
+ // Rebuild the file content
1350
+ const beforeGroup = lines.slice(0, groupStartLine + 1)
1351
+ const afterGroup = lines.slice(groupEndLine)
1352
+
1353
+ // Find non-task lines in the group (empty lines, etc.)
1354
+ const groupNonTaskLines = lines
1355
+ .slice(groupStartLine + 1, groupEndLine)
1356
+ .filter((line) => !line.match(/^-\s+\[[ xX]\]/))
1357
+ .filter((line) => line.trim() === '')
1358
+
1359
+ const newContent = [
1360
+ ...beforeGroup,
1361
+ '',
1362
+ ...reorderedTaskLines,
1363
+ ...groupNonTaskLines,
1364
+ ...afterGroup,
1365
+ ].join('\n')
1366
+
1367
+ await writeFile(tasksPath, newContent, 'utf-8')
1368
+
1369
+ res.json({ success: true })
1370
+ } catch (error) {
1371
+ console.error('Error reordering tasks:', error)
1372
+ res.status(500).json({ success: false, error: 'Failed to reorder tasks' })
1373
+ }
1374
+ })
1375
+
1376
+ // ==================== CLAUDE CODE EXECUTION ====================
1377
+
1378
+ // POST /api/claude/execute - Execute a task with Claude Code (SSE streaming)
1379
+ app.post('/api/claude/execute', async (req, res) => {
1380
+ try {
1381
+ const paths = await getProjectPaths()
1382
+ if (!paths) {
1383
+ return res.status(400).json({ success: false, error: 'No active project' })
1384
+ }
1385
+
1386
+ const { changeId, taskId, taskTitle, context, model } = req.body
1387
+ // model: 'haiku' | 'sonnet' | 'opus' (default: sonnet)
1388
+ const project = await getActiveProject()
1389
+ if (!project) {
1390
+ return res.status(400).json({ success: false, error: 'No active project' })
1391
+ }
1392
+
1393
+ // Build the prompt for Claude using OpenSpecPromptBuilder
1394
+ // This provides full context: CLAUDE.md, proposal.md, design.md, tasks.md, specs/
1395
+ let prompt: string
1396
+ try {
1397
+ const promptBuilder = new OpenSpecPromptBuilder(
1398
+ project.path,
1399
+ changeId,
1400
+ 'single', // 단일 태스크 모드
1401
+ taskId,
1402
+ taskTitle // 태스크 제목으로도 검색 가능
1403
+ )
1404
+ prompt = await promptBuilder.build()
1405
+
1406
+ // 추가 컨텍스트가 있으면 append
1407
+ if (context) {
1408
+ prompt += `\n\n---\n\n## 추가 컨텍스트\n\n${context}`
1409
+ }
1410
+
1411
+ // 태스크 제목 명시
1412
+ prompt += `\n\n---\n\n**실행할 태스크**: ${taskTitle}`
1413
+
1414
+ console.log(
1415
+ '[Claude Execute] Built prompt with OpenSpecPromptBuilder, length:',
1416
+ prompt.length
1417
+ )
1418
+ } catch (buildError) {
1419
+ console.error('[Claude Execute] Failed to build prompt:', buildError)
1420
+ // 폴백: 기본 프롬프트 사용
1421
+ prompt = `당신은 OpenSpec 프로젝트의 태스크를 실행하는 AI입니다.
1422
+
1423
+ ## 현재 작업
1424
+ - Change: ${changeId}
1425
+ - Task ID: ${taskId}
1426
+ - Task: ${taskTitle}
1427
+
1428
+ ## 컨텍스트
1429
+ ${context || '추가 컨텍스트 없음'}
1430
+
1431
+ ## 지시사항
1432
+ 1. 위 태스크를 완료하세요
1433
+ 2. 필요한 파일을 읽고, 수정하거나 생성하세요
1434
+ 3. 작업이 완료되면 결과를 요약해주세요
1435
+ 4. 에러가 발생하면 명확하게 보고해주세요
1436
+
1437
+ 작업을 시작하세요.`
1438
+ }
1439
+
1440
+ // Set up SSE
1441
+ res.setHeader('Content-Type', 'text/event-stream')
1442
+ res.setHeader('Cache-Control', 'no-cache')
1443
+ res.setHeader('Connection', 'keep-alive')
1444
+ res.setHeader('X-Accel-Buffering', 'no')
1445
+
1446
+ // Generate unique run ID
1447
+ const runId = `${changeId}-${taskId}-${Date.now()}`
1448
+
1449
+ // Send initial event
1450
+ res.write(`data: ${JSON.stringify({ type: 'start', runId, taskId, changeId })}\n\n`)
1451
+
1452
+ // Spawn Claude CLI process using node-pty for real TTY support
1453
+ // This enables stream-json output format which requires TTY
1454
+ // Based on official docs: https://code.claude.com/docs/en/headless
1455
+ console.log('[Claude Execute] Running with prompt length:', prompt.length)
1456
+ console.log('[Claude Execute] CWD:', project.path)
1457
+
1458
+ // Save prompt to temp file
1459
+ const tmpPromptPath = join('/tmp', `claude-prompt-${runId}.txt`)
1460
+ await writeFile(tmpPromptPath, prompt, 'utf-8')
1461
+
1462
+ // Build model argument
1463
+ const modelArg = model && ['haiku', 'sonnet', 'opus'].includes(model) ? `--model ${model}` : ''
1464
+
1465
+ console.log('[Claude Execute] Using model:', model || 'default (sonnet)')
1466
+
1467
+ // Use node-pty to spawn with real TTY - enables stream-json output
1468
+ const ptyProcess = pty.spawn(
1469
+ 'bash',
1470
+ [
1471
+ '-c',
1472
+ `cat '${tmpPromptPath}' | /opt/homebrew/bin/claude -p --verbose --output-format stream-json --dangerously-skip-permissions ${modelArg}`,
1473
+ ],
1474
+ {
1475
+ name: 'xterm-color',
1476
+ cols: 200,
1477
+ rows: 50,
1478
+ cwd: project.path,
1479
+ env: { ...process.env } as Record<string, string>,
1480
+ }
1481
+ )
1482
+
1483
+ console.log('[Claude Execute] PTY Process PID:', ptyProcess.pid)
1484
+
1485
+ const taskState = {
1486
+ process: ptyProcess,
1487
+ output: [] as string[],
1488
+ status: 'running' as 'running' | 'completed' | 'error',
1489
+ startedAt: new Date(),
1490
+ }
1491
+ runningTasks.set(runId, taskState)
1492
+
1493
+ let buffer = ''
1494
+
1495
+ ptyProcess.onData((data: string) => {
1496
+ console.log('[Claude Execute] pty data:', data.slice(0, 200))
1497
+ buffer += data
1498
+ const lines = buffer.split('\n')
1499
+ buffer = lines.pop() || ''
1500
+
1501
+ for (const line of lines) {
1502
+ if (!line.trim()) continue
1503
+
1504
+ // Remove ANSI escape codes that PTY might add
1505
+ // eslint-disable-next-line no-control-regex
1506
+ const cleanLine = line.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').trim()
1507
+ if (!cleanLine) continue
1508
+
1509
+ try {
1510
+ const parsed = JSON.parse(cleanLine)
1511
+ taskState.output.push(cleanLine)
1512
+
1513
+ // Forward to client
1514
+ res.write(`data: ${JSON.stringify({ type: 'output', data: parsed })}\n\n`)
1515
+
1516
+ // Check if this is the final result - terminate process
1517
+ if (parsed.type === 'result') {
1518
+ console.log('[Claude Execute] Result received, terminating process')
1519
+ // Small delay to ensure the result is sent to client
1520
+ setTimeout(() => {
1521
+ try {
1522
+ ptyProcess.kill()
1523
+ } catch {
1524
+ // Process might already be dead
1525
+ }
1526
+ }, 100)
1527
+ }
1528
+ } catch {
1529
+ // Non-JSON output, send as text
1530
+ res.write(`data: ${JSON.stringify({ type: 'text', content: cleanLine })}\n\n`)
1531
+ }
1532
+ }
1533
+ })
1534
+
1535
+ // node-pty uses onExit instead of 'close' event
1536
+ ptyProcess.onExit(async ({ exitCode: code }) => {
1537
+ const status = code === 0 ? 'completed' : 'error'
1538
+ taskState.status = status
1539
+
1540
+ // Clean up temp prompt file
1541
+ try {
1542
+ const { unlink } = await import('fs/promises')
1543
+ await unlink(tmpPromptPath)
1544
+ } catch {
1545
+ // Ignore cleanup errors
1546
+ }
1547
+
1548
+ // Save execution log
1549
+ try {
1550
+ const logsDir = join(project.path, '.zyflow', 'logs', changeId)
1551
+ await mkdir(logsDir, { recursive: true })
1552
+ const logPath = join(logsDir, `${taskId}-${Date.now()}.json`)
1553
+ await writeFile(
1554
+ logPath,
1555
+ JSON.stringify(
1556
+ {
1557
+ runId,
1558
+ changeId,
1559
+ taskId,
1560
+ taskTitle,
1561
+ status,
1562
+ startedAt: taskState.startedAt,
1563
+ completedAt: new Date(),
1564
+ output: taskState.output,
1565
+ },
1566
+ null,
1567
+ 2
1568
+ )
1569
+ )
1570
+ } catch (err) {
1571
+ console.error('Failed to save log:', err)
1572
+ }
1573
+
1574
+ // Auto-complete task on successful execution is disabled for now
1575
+ // The task completion should be done by Claude itself via tasks.md update
1576
+ const taskAutoCompleted = false
1577
+
1578
+ res.write(
1579
+ `data: ${JSON.stringify({
1580
+ type: 'complete',
1581
+ runId,
1582
+ status,
1583
+ exitCode: code,
1584
+ taskAutoCompleted,
1585
+ })}\n\n`
1586
+ )
1587
+ res.end()
1588
+
1589
+ // Cleanup after 5 minutes
1590
+ setTimeout(() => runningTasks.delete(runId), 5 * 60 * 1000)
1591
+ })
1592
+
1593
+ // Handle client disconnect
1594
+ req.on('close', () => {
1595
+ if (taskState.status === 'running') {
1596
+ ptyProcess.kill()
1597
+ }
1598
+ })
1599
+ } catch (error) {
1600
+ console.error('Error executing Claude:', error)
1601
+ res.status(500).json({ success: false, error: 'Failed to execute Claude' })
1602
+ }
1603
+ })
1604
+
1605
+ // GET /api/claude/status/:runId - Get status of a running task
1606
+ app.get('/api/claude/status/:runId', (req, res) => {
1607
+ const task = runningTasks.get(req.params.runId)
1608
+ if (!task) {
1609
+ return res.json({ success: true, data: { status: 'not_found' } })
1610
+ }
1611
+ res.json({
1612
+ success: true,
1613
+ data: {
1614
+ status: task.status,
1615
+ startedAt: task.startedAt,
1616
+ outputLength: task.output.length,
1617
+ },
1618
+ })
1619
+ })
1620
+
1621
+ // POST /api/claude/stop/:runId - Stop a running task
1622
+ app.post('/api/claude/stop/:runId', (req, res) => {
1623
+ const task = runningTasks.get(req.params.runId)
1624
+ if (!task || task.status !== 'running') {
1625
+ return res.json({ success: false, error: 'Task not running' })
1626
+ }
1627
+ task.process.kill('SIGTERM')
1628
+ task.status = 'error'
1629
+ res.json({ success: true })
1630
+ })
1631
+
1632
+ // GET /api/claude/logs/:changeId - Get execution logs for a change
1633
+ app.get('/api/claude/logs/:changeId', async (req, res) => {
1634
+ try {
1635
+ const paths = await getProjectPaths()
1636
+ if (!paths) {
1637
+ return res.status(400).json({ success: false, error: 'No active project' })
1638
+ }
1639
+
1640
+ const project = await getActiveProject()
1641
+ if (!project) {
1642
+ return res.status(400).json({ success: false, error: 'No active project' })
1643
+ }
1644
+
1645
+ const logsDir = join(project.path, '.zyflow', 'logs', req.params.changeId)
1646
+ let files: string[] = []
1647
+ try {
1648
+ const entries = await readdir(logsDir)
1649
+ files = entries.filter((f) => f.endsWith('.json'))
1650
+ } catch {
1651
+ // No logs directory yet
1652
+ }
1653
+
1654
+ const logs = []
1655
+ for (const file of files.slice(-20)) {
1656
+ // Last 20 logs
1657
+ try {
1658
+ const content = await readFile(join(logsDir, file), 'utf-8')
1659
+ logs.push(JSON.parse(content))
1660
+ } catch {
1661
+ // Skip invalid logs
1662
+ }
1663
+ }
1664
+
1665
+ res.json({ success: true, data: { logs } })
1666
+ } catch (error) {
1667
+ console.error('Error reading logs:', error)
1668
+ res.status(500).json({ success: false, error: 'Failed to read logs' })
1669
+ }
1670
+ })
1671
+
1672
+ // ==================== TASK MANAGEMENT (SQLite) ====================
1673
+
1674
+ // Initialize task DB - 항상 zyflow 중앙 DB 사용
1675
+ // 모든 프로젝트의 데이터는 zyflow/.zyflow/tasks.db에 저장됨
1676
+ async function initTaskDb() {
1677
+ // process.cwd()의 DB를 사용 (zyflow 디렉토리)
1678
+ initDb()
1679
+ }
1680
+
1681
+ // GET /api/tasks - List all tasks
1682
+ app.get('/api/tasks', async (req, res) => {
1683
+ try {
1684
+ await initTaskDb()
1685
+ const { status, priority, tags, kanban } = req.query
1686
+
1687
+ if (kanban === 'true') {
1688
+ const tasksByStatus = getTasksByStatus()
1689
+ return res.json({ success: true, data: { tasksByStatus } })
1690
+ }
1691
+
1692
+ const tasks = listTasks({
1693
+ status: status as TaskStatus | undefined,
1694
+ priority: priority as TaskPriority | undefined,
1695
+ tags: tags ? (tags as string).split(',') : undefined,
1696
+ })
1697
+
1698
+ res.json({ success: true, data: { tasks } })
1699
+ } catch (error) {
1700
+ console.error('Error listing tasks:', error)
1701
+ res.status(500).json({ success: false, error: 'Failed to list tasks' })
1702
+ }
1703
+ })
1704
+
1705
+ // POST /api/tasks - Create a new task
1706
+ app.post('/api/tasks', async (req, res) => {
1707
+ try {
1708
+ await initTaskDb()
1709
+ const { title, description, status, priority, tags, assignee } = req.body
1710
+
1711
+ if (!title) {
1712
+ return res.status(400).json({ success: false, error: 'Title is required' })
1713
+ }
1714
+
1715
+ const project = await getActiveProject()
1716
+ const task = createTask({
1717
+ title,
1718
+ description,
1719
+ status: status as TaskStatus,
1720
+ priority: priority as TaskPriority,
1721
+ tags,
1722
+ assignee,
1723
+ projectId: project?.id || 'default',
1724
+ })
1725
+
1726
+ res.json({ success: true, data: { task } })
1727
+ } catch (error) {
1728
+ console.error('Error creating task:', error)
1729
+ res.status(500).json({ success: false, error: 'Failed to create task' })
1730
+ }
1731
+ })
1732
+
1733
+ // GET /api/tasks/archived - Get archived tasks with pagination
1734
+ // NOTE: This route MUST be defined before /api/tasks/:id to avoid 'archived' being matched as :id
1735
+ app.get('/api/tasks/archived', async (req, res) => {
1736
+ try {
1737
+ await initTaskDb()
1738
+ const page = parseInt(req.query.page as string, 10) || 1
1739
+ const limit = parseInt(req.query.limit as string, 10) || 20
1740
+ const search = req.query.search as string | undefined
1741
+
1742
+ // Get archived tasks only
1743
+ let archivedTasks = listTasks({
1744
+ status: 'archived',
1745
+ includeArchived: true,
1746
+ orderBy: 'updatedAt',
1747
+ orderDir: 'desc',
1748
+ })
1749
+
1750
+ // Filter by search query
1751
+ if (search) {
1752
+ const query = search.toLowerCase()
1753
+ archivedTasks = archivedTasks.filter(
1754
+ (task) =>
1755
+ task.title.toLowerCase().includes(query) ||
1756
+ task.description?.toLowerCase().includes(query) ||
1757
+ task.id.toString().includes(query)
1758
+ )
1759
+ }
1760
+
1761
+ const total = archivedTasks.length
1762
+ const totalPages = Math.ceil(total / limit)
1763
+ const offset = (page - 1) * limit
1764
+ const tasks = archivedTasks.slice(offset, offset + limit)
1765
+
1766
+ res.json({
1767
+ success: true,
1768
+ data: {
1769
+ tasks,
1770
+ pagination: {
1771
+ page,
1772
+ limit,
1773
+ total,
1774
+ totalPages,
1775
+ },
1776
+ },
1777
+ })
1778
+ } catch (error) {
1779
+ console.error('Error getting archived tasks:', error)
1780
+ res.status(500).json({ success: false, error: 'Failed to get archived tasks' })
1781
+ }
1782
+ })
1783
+
1784
+ // GET /api/tasks/search - Search tasks
1785
+ // NOTE: This route MUST be defined before /api/tasks/:id
1786
+ app.get('/api/tasks/search', async (req, res) => {
1787
+ try {
1788
+ await initTaskDb()
1789
+ const { q, status, priority, limit } = req.query
1790
+
1791
+ if (!q) {
1792
+ return res.status(400).json({ success: false, error: 'Query is required' })
1793
+ }
1794
+
1795
+ const tasks = searchTasks(q as string, {
1796
+ status: status as string | undefined,
1797
+ priority: priority as string | undefined,
1798
+ limit: limit ? parseInt(limit as string, 10) : undefined,
1799
+ })
1800
+
1801
+ res.json({ success: true, data: { tasks } })
1802
+ } catch (error) {
1803
+ console.error('Error searching tasks:', error)
1804
+ res.status(500).json({ success: false, error: 'Failed to search tasks' })
1805
+ }
1806
+ })
1807
+
1808
+ // GET /api/tasks/:id - Get a single task
1809
+ app.get('/api/tasks/:id', async (req, res) => {
1810
+ try {
1811
+ await initTaskDb()
1812
+ const task = getTask(req.params.id)
1813
+
1814
+ if (!task) {
1815
+ return res.status(404).json({ success: false, error: 'Task not found' })
1816
+ }
1817
+
1818
+ res.json({ success: true, data: { task } })
1819
+ } catch (error) {
1820
+ console.error('Error getting task:', error)
1821
+ res.status(500).json({ success: false, error: 'Failed to get task' })
1822
+ }
1823
+ })
1824
+
1825
+ // PATCH /api/tasks/:id - Update a task
1826
+ app.patch('/api/tasks/:id', async (req, res) => {
1827
+ try {
1828
+ await initTaskDb()
1829
+ const { title, description, status, priority, tags, assignee, order } = req.body
1830
+
1831
+ const task = updateTask(req.params.id, {
1832
+ title,
1833
+ description,
1834
+ status: status as TaskStatus,
1835
+ priority: priority as TaskPriority,
1836
+ tags,
1837
+ assignee,
1838
+ order,
1839
+ })
1840
+
1841
+ if (!task) {
1842
+ return res.status(404).json({ success: false, error: 'Task not found' })
1843
+ }
1844
+
1845
+ res.json({ success: true, data: { task } })
1846
+ } catch (error) {
1847
+ console.error('Error updating task:', error)
1848
+ res.status(500).json({ success: false, error: 'Failed to update task' })
1849
+ }
1850
+ })
1851
+
1852
+ // DELETE /api/tasks/:id - Delete a task
1853
+ app.delete('/api/tasks/:id', async (req, res) => {
1854
+ try {
1855
+ await initTaskDb()
1856
+ const deleted = deleteTask(req.params.id)
1857
+
1858
+ if (!deleted) {
1859
+ return res.status(404).json({ success: false, error: 'Task not found' })
1860
+ }
1861
+
1862
+ res.json({ success: true })
1863
+ } catch (error) {
1864
+ console.error('Error deleting task:', error)
1865
+ res.status(500).json({ success: false, error: 'Failed to delete task' })
1866
+ }
1867
+ })
1868
+
1869
+ // POST /api/tasks/:id/archive - Archive a task
1870
+ app.post('/api/tasks/:id/archive', async (req, res) => {
1871
+ try {
1872
+ await initTaskDb()
1873
+ const task = archiveTask(req.params.id)
1874
+
1875
+ if (!task) {
1876
+ return res.status(404).json({ success: false, error: 'Task not found' })
1877
+ }
1878
+
1879
+ res.json({ success: true, data: { task } })
1880
+ } catch (error) {
1881
+ console.error('Error archiving task:', error)
1882
+ res.status(500).json({ success: false, error: 'Failed to archive task' })
1883
+ }
1884
+ })
1885
+
1886
+ // POST /api/tasks/:id/unarchive - Unarchive a task
1887
+ app.post('/api/tasks/:id/unarchive', async (req, res) => {
1888
+ try {
1889
+ await initTaskDb()
1890
+ const task = unarchiveTask(req.params.id)
1891
+
1892
+ if (!task) {
1893
+ return res.status(404).json({ success: false, error: 'Task not found or not archived' })
1894
+ }
1895
+
1896
+ res.json({ success: true, data: { task } })
1897
+ } catch (error) {
1898
+ console.error('Error unarchiving task:', error)
1899
+ res.status(500).json({ success: false, error: 'Failed to unarchive task' })
1900
+ }
1901
+ })
1902
+
1903
+ // ==================== FLOW API (DB 기반 Change 관리) ====================
1904
+
1905
+ const STAGES: Stage[] = ['spec', 'changes', 'task', 'code', 'test', 'commit', 'docs']
1906
+
1907
+ // Helper: Change의 stages 집계 정보 계산
1908
+ function getChangeStages(changeId: string, projectId?: string) {
1909
+ const sqlite = getSqlite()
1910
+ const stages: Record<Stage, { total: number; completed: number; tasks: unknown[] }> = {
1911
+ spec: { total: 0, completed: 0, tasks: [] },
1912
+ changes: { total: 0, completed: 0, tasks: [] },
1913
+ task: { total: 0, completed: 0, tasks: [] },
1914
+ code: { total: 0, completed: 0, tasks: [] },
1915
+ test: { total: 0, completed: 0, tasks: [] },
1916
+ commit: { total: 0, completed: 0, tasks: [] },
1917
+ docs: { total: 0, completed: 0, tasks: [] },
1918
+ }
1919
+
1920
+ // project_id 필터 추가: 같은 changeId가 여러 프로젝트에 있을 수 있으므로 project_id로 필터링
1921
+ const tasks = sqlite
1922
+ .prepare(
1923
+ projectId
1924
+ ? `SELECT * FROM tasks WHERE change_id = ? AND project_id = ? AND status != 'archived' ORDER BY stage, group_order, sub_order, task_order, "order"`
1925
+ : `SELECT * FROM tasks WHERE change_id = ? AND status != 'archived' ORDER BY stage, group_order, sub_order, task_order, "order"`
1926
+ )
1927
+ .all(projectId ? [changeId, projectId] : [changeId]) as Array<{
1928
+ id: number
1929
+ change_id: string
1930
+ stage: Stage
1931
+ title: string
1932
+ description: string | null
1933
+ status: string
1934
+ priority: string
1935
+ tags: string | null
1936
+ assignee: string | null
1937
+ order: number
1938
+ group_title: string | null
1939
+ group_order: number
1940
+ task_order: number
1941
+ major_title: string | null
1942
+ sub_order: number | null
1943
+ display_id: string | null
1944
+ created_at: number
1945
+ updated_at: number
1946
+ archived_at: number | null
1947
+ }>
1948
+
1949
+ for (const task of tasks) {
1950
+ const stage = task.stage as Stage
1951
+ stages[stage].total++
1952
+ if (task.status === 'done') {
1953
+ stages[stage].completed++
1954
+ }
1955
+ stages[stage].tasks.push({
1956
+ id: task.id,
1957
+ changeId: task.change_id,
1958
+ stage: task.stage,
1959
+ title: task.title,
1960
+ description: task.description,
1961
+ status: task.status,
1962
+ priority: task.priority,
1963
+ tags: task.tags ? JSON.parse(task.tags) : [],
1964
+ assignee: task.assignee,
1965
+ order: task.order,
1966
+ groupTitle: task.group_title,
1967
+ groupOrder: task.group_order,
1968
+ taskOrder: task.task_order,
1969
+ majorTitle: task.major_title,
1970
+ subOrder: task.sub_order,
1971
+ displayId: task.display_id,
1972
+ createdAt: new Date(task.created_at).toISOString(),
1973
+ updatedAt: new Date(task.updated_at).toISOString(),
1974
+ archivedAt: task.archived_at ? new Date(task.archived_at).toISOString() : null,
1975
+ })
1976
+ }
1977
+
1978
+ return stages
1979
+ }
1980
+
1981
+ // Helper: Change 진행률 계산
1982
+ function calculateProgress(stages: Record<Stage, { total: number; completed: number }>): number {
1983
+ let totalTasks = 0
1984
+ let completedTasks = 0
1985
+ for (const stage of STAGES) {
1986
+ totalTasks += stages[stage].total
1987
+ completedTasks += stages[stage].completed
1988
+ }
1989
+ return totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
1990
+ }
1991
+
1992
+ // Helper: 현재 stage 결정 (가장 먼저 미완료된 stage)
1993
+ function determineCurrentStage(stages: Record<Stage, { total: number; completed: number }>): Stage {
1994
+ for (const stage of STAGES) {
1995
+ if (stages[stage].total > stages[stage].completed) {
1996
+ return stage
1997
+ }
1998
+ }
1999
+ return 'docs' // 모든 stage 완료시
2000
+ }
2001
+
2002
+ // GET /api/flow/changes/counts - 프로젝트별 Change 수 (상태별 집계)
2003
+ app.get('/api/flow/changes/counts', async (req, res) => {
2004
+ try {
2005
+ await initTaskDb()
2006
+ const config = await loadConfig()
2007
+ const sqlite = getSqlite()
2008
+
2009
+ // 쿼리 파라미터에서 상태 필터링 옵션 가져오기
2010
+ const { status } = req.query // 'active', 'completed', 'all' (기본값: 'active')
2011
+
2012
+ const counts: Record<string, number> = {}
2013
+ const detailedCounts: Record<string, { active: number; completed: number; total: number }> = {}
2014
+
2015
+ // 단일 쿼리로 모든 프로젝트의 집계 데이터 가져오기 (성능 최적화)
2016
+ const projectIds = config.projects.map((p) => p.id)
2017
+ const placeholders = projectIds.map(() => '?').join(',')
2018
+
2019
+ // 상세 집계 쿼리 (인덱스 활용)
2020
+ const detailedResults = sqlite
2021
+ .prepare(
2022
+ `
2023
+ SELECT
2024
+ project_id,
2025
+ SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active,
2026
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
2027
+ COUNT(*) as total
2028
+ FROM changes
2029
+ WHERE project_id IN (${placeholders})
2030
+ GROUP BY project_id
2031
+ `
2032
+ )
2033
+ .all(...projectIds) as Array<{
2034
+ project_id: string
2035
+ active: number
2036
+ completed: number
2037
+ total: number
2038
+ }>
2039
+
2040
+ // 결과 매핑
2041
+ for (const project of config.projects) {
2042
+ const projectResult = detailedResults.find((r) => r.project_id === project.id)
2043
+
2044
+ // 기존 단일 집계 (하위 호환성)
2045
+ let count = 0
2046
+ if (status === 'active') {
2047
+ count = projectResult?.active ?? 0
2048
+ } else if (status === 'completed') {
2049
+ count = projectResult?.completed ?? 0
2050
+ } else {
2051
+ count = projectResult?.total ?? 0
2052
+ }
2053
+ counts[project.id] = count
2054
+
2055
+ // 상세 집계
2056
+ detailedCounts[project.id] = {
2057
+ active: projectResult?.active ?? 0,
2058
+ completed: projectResult?.completed ?? 0,
2059
+ total: projectResult?.total ?? 0,
2060
+ }
2061
+ }
2062
+
2063
+ // 항상 상세 정보를 포함하여 반환 (하위 호환성 유지)
2064
+ const responseData = { counts, detailed: detailedCounts }
2065
+
2066
+ res.json({ success: true, data: responseData })
2067
+ } catch (error) {
2068
+ console.error('Error getting change counts:', error)
2069
+ res.status(500).json({
2070
+ success: false,
2071
+ error: error instanceof Error ? error.message : 'Unknown error',
2072
+ })
2073
+ }
2074
+ })
2075
+
2076
+ // GET /api/flow/changes - Flow Changes 목록 (모든 프로젝트)
2077
+ app.get('/api/flow/changes', async (_req, res) => {
2078
+ try {
2079
+ await initTaskDb()
2080
+ const config = await loadConfig()
2081
+
2082
+ if (config.projects.length === 0) {
2083
+ return res.json({ success: true, data: { changes: [] } })
2084
+ }
2085
+
2086
+ const sqlite = getSqlite()
2087
+ const allChanges: Array<{
2088
+ id: string
2089
+ projectId: string
2090
+ projectName: string
2091
+ title: string
2092
+ specPath: string | null
2093
+ status: string
2094
+ currentStage: string
2095
+ progress: number
2096
+ createdAt: string
2097
+ updatedAt: string
2098
+ stages: ReturnType<typeof getChangeStages>
2099
+ type: 'openspec' | 'moai-spec'
2100
+ }> = []
2101
+
2102
+ // Scan all projects
2103
+ for (const project of config.projects) {
2104
+ // Get OpenSpec changes from DB
2105
+ const dbChanges = sqlite
2106
+ .prepare(
2107
+ `
2108
+ SELECT * FROM changes
2109
+ WHERE project_id = ? AND status != 'archived'
2110
+ ORDER BY updated_at DESC
2111
+ `
2112
+ )
2113
+ .all(project.id) as Array<{
2114
+ id: string
2115
+ project_id: string
2116
+ title: string
2117
+ spec_path: string | null
2118
+ status: ChangeStatus
2119
+ current_stage: Stage
2120
+ progress: number
2121
+ created_at: number
2122
+ updated_at: number
2123
+ }>
2124
+
2125
+ for (const c of dbChanges) {
2126
+ const stages = getChangeStages(c.id, project.path)
2127
+ const progress = calculateProgress(stages)
2128
+ const currentStage = determineCurrentStage(stages)
2129
+
2130
+ allChanges.push({
2131
+ id: c.id,
2132
+ projectId: c.project_id,
2133
+ projectName: project.name,
2134
+ title: c.title,
2135
+ specPath: c.spec_path,
2136
+ status: c.status,
2137
+ currentStage,
2138
+ progress,
2139
+ createdAt: new Date(c.created_at).toISOString(),
2140
+ updatedAt: new Date(c.updated_at).toISOString(),
2141
+ stages,
2142
+ type: 'openspec' as const,
2143
+ })
2144
+ }
2145
+
2146
+ // Scan MoAI SPECs from .moai/specs/ directory
2147
+ try {
2148
+ const moaiSpecs = await scanMoaiSpecs(project.path)
2149
+ for (const spec of moaiSpecs) {
2150
+ if (spec.status === 'archived') continue
2151
+
2152
+ allChanges.push({
2153
+ id: spec.id,
2154
+ projectId: project.id,
2155
+ projectName: project.name,
2156
+ title: spec.title,
2157
+ specPath: `.moai/specs/${spec.id}`,
2158
+ status: spec.status === 'complete' ? 'done' : spec.status === 'active' ? 'in_progress' : 'pending',
2159
+ currentStage: spec.status === 'complete' ? 'sync' : spec.status === 'active' ? 'run' : 'plan',
2160
+ progress: spec.tagCount > 0 ? Math.round((spec.completedTags / spec.tagCount) * 100) : 0,
2161
+ createdAt: spec.createdAt || new Date().toISOString(),
2162
+ updatedAt: spec.updatedAt || new Date().toISOString(),
2163
+ stages: [],
2164
+ type: 'moai-spec' as const,
2165
+ })
2166
+ }
2167
+ } catch (err) {
2168
+ console.warn(`Failed to scan MoAI SPECs for ${project.name}:`, err)
2169
+ }
2170
+ }
2171
+
2172
+ // Sort by updatedAt (most recent first)
2173
+ const changes = allChanges.sort((a, b) => {
2174
+ const aTime = new Date(a.updatedAt).getTime()
2175
+ const bTime = new Date(b.updatedAt).getTime()
2176
+ return bTime - aTime
2177
+ })
2178
+
2179
+ res.json({ success: true, data: { changes } })
2180
+ } catch (error) {
2181
+ console.error('Error listing flow changes:', error)
2182
+ res.status(500).json({ success: false, error: 'Failed to list flow changes' })
2183
+ }
2184
+ })
2185
+
2186
+ // GET /api/flow/changes/:id - Flow Change 상세 (stages 포함)
2187
+ app.get('/api/flow/changes/:id', async (req, res) => {
2188
+ try {
2189
+ await initTaskDb()
2190
+ const config = await loadConfig()
2191
+
2192
+ // 활성 프로젝트에서 우선 조회 (같은 changeId가 여러 프로젝트에 있을 수 있음)
2193
+ const activeProjectId = config.activeProjectId
2194
+ const sqlite = getSqlite()
2195
+
2196
+ let change = activeProjectId
2197
+ ? (sqlite
2198
+ .prepare(`SELECT * FROM changes WHERE id = ? AND project_id = ?`)
2199
+ .get(req.params.id, activeProjectId) as {
2200
+ id: string
2201
+ project_id: string
2202
+ title: string
2203
+ spec_path: string | null
2204
+ status: ChangeStatus
2205
+ current_stage: Stage
2206
+ progress: number
2207
+ created_at: number
2208
+ updated_at: number
2209
+ } | undefined)
2210
+ : undefined
2211
+
2212
+ // 활성 프로젝트에 없으면 전체에서 조회 (fallback)
2213
+ if (!change) {
2214
+ change = sqlite
2215
+ .prepare(`SELECT * FROM changes WHERE id = ?`)
2216
+ .get(req.params.id) as typeof change
2217
+ }
2218
+
2219
+ if (!change) {
2220
+ return res.status(404).json({ success: false, error: 'Change not found' })
2221
+ }
2222
+
2223
+ // Change가 속한 프로젝트 경로 찾기
2224
+ const project = config.projects.find((p) => p.id === change.project_id)
2225
+ if (!project) {
2226
+ return res.status(404).json({ success: false, error: 'Project not found for this change' })
2227
+ }
2228
+
2229
+ // project_id를 전달하여 해당 프로젝트의 Tasks만 가져오기
2230
+ const stages = getChangeStages(change.id, change.project_id)
2231
+ const progress = calculateProgress(stages)
2232
+ const currentStage = determineCurrentStage(stages)
2233
+
2234
+ // Git에서 실제 날짜 가져오기
2235
+ let gitCreatedAt: string | null = null
2236
+ let gitUpdatedAt: string | null = null
2237
+ try {
2238
+ const relativeChangeDir = `openspec/changes/${change.id}`
2239
+ // 최신 커밋 날짜 (수정일)
2240
+ const { stdout: updatedStdout } = await execAsync(
2241
+ `git log -1 --format="%aI" -- "${relativeChangeDir}"`,
2242
+ { cwd: project.path }
2243
+ )
2244
+ if (updatedStdout.trim()) {
2245
+ gitUpdatedAt = updatedStdout.trim()
2246
+ }
2247
+ // 최초 커밋 날짜 (생성일)
2248
+ const { stdout: createdStdout } = await execAsync(
2249
+ `git log --diff-filter=A --format="%aI" -- "${relativeChangeDir}" | tail -1`,
2250
+ { cwd: project.path }
2251
+ )
2252
+ if (createdStdout.trim()) {
2253
+ gitCreatedAt = createdStdout.trim()
2254
+ }
2255
+ } catch {
2256
+ // Git 명령 실패 시 DB 값 사용
2257
+ }
2258
+
2259
+ res.json({
2260
+ success: true,
2261
+ data: {
2262
+ change: {
2263
+ id: change.id,
2264
+ projectId: change.project_id,
2265
+ title: change.title,
2266
+ specPath: change.spec_path,
2267
+ status: change.status,
2268
+ currentStage,
2269
+ progress,
2270
+ createdAt: gitCreatedAt || new Date(change.created_at).toISOString(),
2271
+ updatedAt: gitUpdatedAt || new Date(change.updated_at).toISOString(),
2272
+ },
2273
+ stages,
2274
+ },
2275
+ })
2276
+ } catch (error) {
2277
+ console.error('Error getting flow change:', error)
2278
+ res.status(500).json({ success: false, error: 'Failed to get flow change' })
2279
+ }
2280
+ })
2281
+
2282
+ // POST /api/flow/sync - OpenSpec에서 Changes 동기화 (모든 프로젝트)
2283
+ app.post('/api/flow/sync', async (_req, res) => {
2284
+ try {
2285
+ await initTaskDb()
2286
+ const config = await loadConfig()
2287
+
2288
+ if (!config.projects.length) {
2289
+ return res.json({ success: true, data: { synced: 0, created: 0, updated: 0, projects: 0 } })
2290
+ }
2291
+
2292
+ const sqlite = getSqlite()
2293
+ let totalCreated = 0
2294
+ let totalUpdated = 0
2295
+ let projectsSynced = 0
2296
+
2297
+ // 모든 프로젝트 순회
2298
+ for (const project of config.projects) {
2299
+ const openspecDir = join(project.path, 'openspec', 'changes')
2300
+ let entries
2301
+ try {
2302
+ entries = await readdir(openspecDir, { withFileTypes: true })
2303
+ } catch {
2304
+ // openspec/changes 폴더가 없는 프로젝트는 스킵
2305
+ continue
2306
+ }
2307
+
2308
+ projectsSynced++
2309
+
2310
+ for (const entry of entries) {
2311
+ if (!entry.isDirectory() || entry.name === 'archive') continue
2312
+
2313
+ const changeId = entry.name
2314
+ const changeDir = join(openspecDir, changeId)
2315
+
2316
+ // Read proposal.md for title
2317
+ let title = changeId
2318
+ const specPath = `openspec/changes/${changeId}/proposal.md`
2319
+ try {
2320
+ const proposalPath = join(changeDir, 'proposal.md')
2321
+ const proposalContent = await readFile(proposalPath, 'utf-8')
2322
+ const titleMatch = proposalContent.match(/^#\s+(?:Change:\s+)?(.+)$/m)
2323
+ if (titleMatch) {
2324
+ title = titleMatch[1].trim()
2325
+ }
2326
+ } catch {
2327
+ // proposal.md not found
2328
+ }
2329
+
2330
+ // Check if change exists
2331
+ const existing = sqlite
2332
+ .prepare('SELECT id FROM changes WHERE id = ? AND project_id = ?')
2333
+ .get(changeId, project.id)
2334
+ const now = Date.now()
2335
+
2336
+ if (existing) {
2337
+ sqlite
2338
+ .prepare(
2339
+ `
2340
+ UPDATE changes SET title = ?, spec_path = ?, updated_at = ? WHERE id = ? AND project_id = ?
2341
+ `
2342
+ )
2343
+ .run(title, specPath, now, changeId, project.id)
2344
+ totalUpdated++
2345
+ } else {
2346
+ sqlite
2347
+ .prepare(
2348
+ `
2349
+ INSERT INTO changes (id, project_id, title, spec_path, status, current_stage, progress, created_at, updated_at)
2350
+ VALUES (?, ?, ?, ?, 'active', 'spec', 0, ?, ?)
2351
+ `
2352
+ )
2353
+ .run(changeId, project.id, title, specPath, now, now)
2354
+ totalCreated++
2355
+ }
2356
+
2357
+ // Sync tasks from tasks.md (3단계 계층 지원)
2358
+ // 전략: displayId 기반 매칭 (가장 안정적, 위치 기반)
2359
+ try {
2360
+ const tasksPath = join(changeDir, 'tasks.md')
2361
+ const tasksContent = await readFile(tasksPath, 'utf-8')
2362
+ const parsed = parseTasksFile(changeId, tasksContent)
2363
+
2364
+ // Extended group type with 3-level hierarchy
2365
+ interface ExtendedGroup {
2366
+ title: string
2367
+ tasks: Array<{
2368
+ title: string
2369
+ completed: boolean
2370
+ lineNumber: number
2371
+ displayId?: string
2372
+ }>
2373
+ majorOrder?: number
2374
+ majorTitle?: string
2375
+ subOrder?: number
2376
+ groupTitle?: string
2377
+ }
2378
+
2379
+ // 파일에서 파싱된 모든 displayId 수집 (삭제 감지용)
2380
+ const parsedDisplayIds = new Set<string>()
2381
+
2382
+ for (const group of parsed.groups as ExtendedGroup[]) {
2383
+ // 3단계 계층 정보 추출
2384
+ const majorOrder = group.majorOrder ?? 1
2385
+ const majorTitle = group.majorTitle ?? group.title
2386
+ const subOrder = group.subOrder ?? 1
2387
+ const groupTitle = group.groupTitle ?? group.title // ### 1.1 Subsection Title
2388
+
2389
+ for (let taskIdx = 0; taskIdx < group.tasks.length; taskIdx++) {
2390
+ const task = group.tasks[taskIdx]
2391
+ const taskOrder = taskIdx + 1
2392
+ const displayId = task.displayId || null
2393
+
2394
+ if (displayId) {
2395
+ parsedDisplayIds.add(displayId)
2396
+ }
2397
+
2398
+ // 매칭 전략 (우선순위 순):
2399
+ // 1. displayId로 매칭 (가장 안정적 - 파서가 순서 기반으로 생성)
2400
+ // 2. change_id + title로 매칭 (displayId가 없는 레거시 데이터용)
2401
+ let existingTask: { id: number } | undefined
2402
+
2403
+ if (displayId) {
2404
+ existingTask = sqlite
2405
+ .prepare(
2406
+ `
2407
+ SELECT id FROM tasks WHERE change_id = ? AND display_id = ?
2408
+ `
2409
+ )
2410
+ .get(changeId, displayId) as { id: number } | undefined
2411
+ }
2412
+
2413
+ // displayId로 못 찾으면 title로 시도 (레거시 호환)
2414
+ if (!existingTask) {
2415
+ existingTask = sqlite
2416
+ .prepare(
2417
+ `
2418
+ SELECT id FROM tasks WHERE change_id = ? AND title = ?
2419
+ `
2420
+ )
2421
+ .get(changeId, task.title) as { id: number } | undefined
2422
+ }
2423
+
2424
+ if (existingTask) {
2425
+ // 기존 태스크 업데이트
2426
+ const newStatus = task.completed ? 'done' : 'todo'
2427
+ sqlite
2428
+ .prepare(
2429
+ `
2430
+ UPDATE tasks
2431
+ SET title = ?,
2432
+ status = ?,
2433
+ group_title = ?,
2434
+ group_order = ?,
2435
+ task_order = ?,
2436
+ major_title = ?,
2437
+ sub_order = ?,
2438
+ display_id = ?,
2439
+ project_id = ?,
2440
+ updated_at = ?
2441
+ WHERE id = ?
2442
+ `
2443
+ )
2444
+ .run(
2445
+ task.title,
2446
+ newStatus,
2447
+ groupTitle,
2448
+ majorOrder,
2449
+ taskOrder,
2450
+ majorTitle,
2451
+ subOrder,
2452
+ displayId,
2453
+ project.id,
2454
+ now,
2455
+ existingTask.id
2456
+ )
2457
+ } else {
2458
+ // 새 태스크 생성
2459
+ // TAG-014: Changed from 'task_openspec' to 'task_moai' sequence
2460
+ sqlite
2461
+ .prepare(`UPDATE sequences SET value = value + 1 WHERE name = 'task_moai'`)
2462
+ .run()
2463
+ const seqResult = sqlite
2464
+ .prepare(`SELECT value FROM sequences WHERE name = 'task_moai'`)
2465
+ .get() as { value: number }
2466
+ const newId = seqResult.value
2467
+
2468
+ sqlite
2469
+ .prepare(
2470
+ `
2471
+ INSERT INTO tasks (
2472
+ id, project_id, change_id, stage, title, status, priority, "order",
2473
+ group_title, group_order, task_order, major_title, sub_order,
2474
+ display_id, origin, created_at, updated_at
2475
+ )
2476
+ VALUES (?, ?, ?, 'task', ?, ?, 'medium', ?, ?, ?, ?, ?, ?, ?, 'moai', ?, ?)
2477
+ `
2478
+ )
2479
+ .run(
2480
+ newId,
2481
+ project.id,
2482
+ changeId,
2483
+ task.title,
2484
+ task.completed ? 'done' : 'todo',
2485
+ task.lineNumber,
2486
+ groupTitle,
2487
+ majorOrder,
2488
+ taskOrder,
2489
+ majorTitle,
2490
+ subOrder,
2491
+ displayId,
2492
+ now,
2493
+ now
2494
+ )
2495
+ }
2496
+ }
2497
+ }
2498
+
2499
+ // 파일에서 삭제된 태스크 정리 (displayId가 있는 태스크 중 파일에 없는 것)
2500
+ // 삭제 대신 archived 상태로 변경 (데이터 보존)
2501
+ if (parsedDisplayIds.size > 0) {
2502
+ const dbTasks = sqlite
2503
+ .prepare(
2504
+ `
2505
+ SELECT id, display_id FROM tasks
2506
+ WHERE change_id = ? AND display_id IS NOT NULL AND status != 'archived'
2507
+ `
2508
+ )
2509
+ .all(changeId) as Array<{ id: number; display_id: string }>
2510
+
2511
+ for (const dbTask of dbTasks) {
2512
+ if (!parsedDisplayIds.has(dbTask.display_id)) {
2513
+ // 파일에서 삭제된 태스크 - archived로 표시
2514
+ sqlite
2515
+ .prepare(
2516
+ `
2517
+ UPDATE tasks SET status = 'archived', archived_at = ?, updated_at = ? WHERE id = ?
2518
+ `
2519
+ )
2520
+ .run(now, now, dbTask.id)
2521
+ }
2522
+ }
2523
+ }
2524
+ } catch {
2525
+ // tasks.md not found or parse error
2526
+ }
2527
+ }
2528
+ }
2529
+
2530
+ res.json({
2531
+ success: true,
2532
+ data: {
2533
+ synced: totalCreated + totalUpdated,
2534
+ created: totalCreated,
2535
+ updated: totalUpdated,
2536
+ projects: projectsSynced,
2537
+ },
2538
+ })
2539
+ } catch (error) {
2540
+ console.error('Error syncing flow changes:', error)
2541
+ res.status(500).json({ success: false, error: 'Failed to sync flow changes' })
2542
+ }
2543
+ })
2544
+
2545
+ // GET /api/flow/tasks - Flow Tasks 목록 (필터링)
2546
+ app.get('/api/flow/tasks', async (req, res) => {
2547
+ try {
2548
+ await initTaskDb()
2549
+ const { changeId, stage, status, standalone, includeArchived } = req.query
2550
+
2551
+ const project = await getActiveProject()
2552
+ if (!project) {
2553
+ return res.status(400).json({ success: false, error: 'No active project' })
2554
+ }
2555
+
2556
+ // 원격 프로젝트: SSH로 tasks.md 실시간 조회 (On-Demand)
2557
+ if (project.remote && changeId) {
2558
+ try {
2559
+ const remotePlugin = await import('@zyflow/remote-plugin')
2560
+ const server = await remotePlugin.getRemoteServerById(project.remote.serverId)
2561
+ if (server) {
2562
+ const tasksPath = `${project.path}/openspec/changes/${changeId}/tasks.md`
2563
+ const tasksContent = await remotePlugin.readRemoteFile(server, tasksPath)
2564
+ const parsed = parseTasksFile(changeId as string, tasksContent)
2565
+
2566
+ // 파싱된 Tasks를 프론트엔드 형식으로 변환
2567
+ const formatted: Array<{
2568
+ id: number
2569
+ changeId: string | null
2570
+ stage: string
2571
+ title: string
2572
+ description: string | null
2573
+ status: string
2574
+ priority: string
2575
+ tags: string[]
2576
+ assignee: string | null
2577
+ order: number
2578
+ displayId: string | null
2579
+ createdAt: string
2580
+ updatedAt: string
2581
+ archivedAt: string | null
2582
+ groupTitle?: string
2583
+ majorTitle?: string
2584
+ }> = []
2585
+
2586
+ let taskId = 1
2587
+ for (const group of parsed.groups) {
2588
+ for (const task of group.tasks) {
2589
+ formatted.push({
2590
+ id: taskId++,
2591
+ changeId: changeId as string,
2592
+ stage: 'task',
2593
+ title: task.title,
2594
+ description: null,
2595
+ status: task.completed ? 'done' : 'todo',
2596
+ priority: 'medium',
2597
+ tags: [],
2598
+ assignee: null,
2599
+ order: task.lineNumber,
2600
+ displayId: task.displayId || null,
2601
+ createdAt: new Date().toISOString(),
2602
+ updatedAt: new Date().toISOString(),
2603
+ archivedAt: null,
2604
+ groupTitle: group.title,
2605
+ majorTitle: (group as { majorTitle?: string }).majorTitle || group.title,
2606
+ })
2607
+ }
2608
+ }
2609
+
2610
+ return res.json({ success: true, data: { tasks: formatted } })
2611
+ }
2612
+ } catch (e) {
2613
+ console.warn('[Remote Tasks] Failed to fetch via SSH, falling back to DB:', e)
2614
+ // SSH 실패 시 DB에서 조회 (fallback)
2615
+ }
2616
+ }
2617
+
2618
+ // 로컬 프로젝트 또는 원격 프로젝트 fallback: DB에서 조회
2619
+ const sqlite = getSqlite()
2620
+ let sql = 'SELECT * FROM tasks WHERE project_id = ?'
2621
+ const params: unknown[] = [project.id]
2622
+
2623
+ if (standalone === 'true') {
2624
+ sql += ' AND change_id IS NULL'
2625
+ } else if (changeId) {
2626
+ sql += ' AND change_id = ?'
2627
+ params.push(changeId)
2628
+ }
2629
+
2630
+ if (stage) {
2631
+ sql += ' AND stage = ?'
2632
+ params.push(stage)
2633
+ }
2634
+
2635
+ if (status) {
2636
+ sql += ' AND status = ?'
2637
+ params.push(status)
2638
+ } else if (includeArchived !== 'true') {
2639
+ sql += " AND status != 'archived'"
2640
+ }
2641
+
2642
+ sql += ' ORDER BY "order", created_at'
2643
+
2644
+ const tasks = sqlite.prepare(sql).all(...params) as Array<{
2645
+ id: number
2646
+ change_id: string | null
2647
+ stage: Stage
2648
+ title: string
2649
+ description: string | null
2650
+ status: string
2651
+ priority: string
2652
+ tags: string | null
2653
+ assignee: string | null
2654
+ order: number
2655
+ display_id: string | null
2656
+ created_at: number
2657
+ updated_at: number
2658
+ archived_at: number | null
2659
+ }>
2660
+
2661
+ const formatted = tasks.map((t) => ({
2662
+ id: t.id,
2663
+ changeId: t.change_id,
2664
+ stage: t.stage,
2665
+ title: t.title,
2666
+ description: t.description,
2667
+ status: t.status,
2668
+ priority: t.priority,
2669
+ tags: t.tags ? JSON.parse(t.tags) : [],
2670
+ assignee: t.assignee,
2671
+ order: t.order,
2672
+ displayId: t.display_id,
2673
+ createdAt: new Date(t.created_at).toISOString(),
2674
+ updatedAt: new Date(t.updated_at).toISOString(),
2675
+ archivedAt: t.archived_at ? new Date(t.archived_at).toISOString() : null,
2676
+ }))
2677
+
2678
+ res.json({ success: true, data: { tasks: formatted } })
2679
+ } catch (error) {
2680
+ console.error('Error listing flow tasks:', error)
2681
+ res.status(500).json({ success: false, error: 'Failed to list flow tasks' })
2682
+ }
2683
+ })
2684
+
2685
+ // POST /api/flow/tasks - Flow Task 생성
2686
+ app.post('/api/flow/tasks', async (req, res) => {
2687
+ try {
2688
+ await initTaskDb()
2689
+ const project = await getActiveProject()
2690
+ if (!project) {
2691
+ return res.status(400).json({ success: false, error: 'No active project' })
2692
+ }
2693
+ const { changeId, stage, title, description, priority } = req.body
2694
+
2695
+ if (!title) {
2696
+ return res.status(400).json({ success: false, error: 'Title is required' })
2697
+ }
2698
+
2699
+ const sqlite = getSqlite()
2700
+ const now = Date.now()
2701
+
2702
+ const result = sqlite
2703
+ .prepare(
2704
+ `
2705
+ INSERT INTO tasks (project_id, change_id, stage, title, description, status, priority, "order", created_at, updated_at)
2706
+ VALUES (?, ?, ?, ?, ?, 'todo', ?, 0, ?, ?)
2707
+ `
2708
+ )
2709
+ .run(
2710
+ project.id,
2711
+ changeId || null,
2712
+ stage || 'task',
2713
+ title,
2714
+ description || null,
2715
+ priority || 'medium',
2716
+ now,
2717
+ now
2718
+ )
2719
+
2720
+ const task = sqlite.prepare('SELECT * FROM tasks WHERE id = ?').get(result.lastInsertRowid)
2721
+
2722
+ // WebSocket으로 태스크 생성 알림
2723
+ emit('task:created', { task })
2724
+
2725
+ res.json({ success: true, data: { task } })
2726
+ } catch (error) {
2727
+ console.error('Error creating flow task:', error)
2728
+ res.status(500).json({ success: false, error: 'Failed to create flow task' })
2729
+ }
2730
+ })
2731
+
2732
+ // GET /api/flow/changes/:id/proposal - Change의 proposal.md 내용
2733
+ app.get('/api/flow/changes/:id/proposal', async (req, res) => {
2734
+ try {
2735
+ const paths = await getProjectPaths()
2736
+ if (!paths) {
2737
+ return res.status(400).json({ success: false, error: 'No active project' })
2738
+ }
2739
+
2740
+ const changeId = req.params.id
2741
+ const proposalPath = join(paths.openspecDir, changeId, 'proposal.md')
2742
+
2743
+ try {
2744
+ const content = await readFile(proposalPath, 'utf-8')
2745
+ res.json({ success: true, data: { changeId, content } })
2746
+ } catch {
2747
+ res.json({ success: true, data: { changeId, content: null } })
2748
+ }
2749
+ } catch (error) {
2750
+ console.error('Error reading proposal:', error)
2751
+ res.status(500).json({ success: false, error: 'Failed to read proposal' })
2752
+ }
2753
+ })
2754
+
2755
+ // GET /api/flow/changes/:id/design - Change의 design.md 내용
2756
+ app.get('/api/flow/changes/:id/design', async (req, res) => {
2757
+ try {
2758
+ const paths = await getProjectPaths()
2759
+ if (!paths) {
2760
+ return res.status(400).json({ success: false, error: 'No active project' })
2761
+ }
2762
+
2763
+ const changeId = req.params.id
2764
+ const designPath = join(paths.openspecDir, changeId, 'design.md')
2765
+
2766
+ try {
2767
+ const content = await readFile(designPath, 'utf-8')
2768
+ res.json({ success: true, data: { changeId, content } })
2769
+ } catch {
2770
+ res.json({ success: true, data: { changeId, content: null } })
2771
+ }
2772
+ } catch (error) {
2773
+ console.error('Error reading design:', error)
2774
+ res.status(500).json({ success: false, error: 'Failed to read design' })
2775
+ }
2776
+ })
2777
+
2778
+ // GET /api/flow/changes/:id/spec - Change의 첫 번째 spec.md 내용
2779
+ app.get('/api/flow/changes/:id/spec', async (req, res) => {
2780
+ try {
2781
+ const paths = await getProjectPaths()
2782
+ if (!paths) {
2783
+ return res.status(400).json({ success: false, error: 'No active project' })
2784
+ }
2785
+
2786
+ const changeId = req.params.id
2787
+ const specsDir = join(paths.openspecDir, changeId, 'specs')
2788
+
2789
+ try {
2790
+ // specs 디렉토리에서 첫 번째 spec 폴더 찾기
2791
+ const specFolders = await readdir(specsDir)
2792
+ if (specFolders.length === 0) {
2793
+ return res.json({ success: true, data: { changeId, content: null, specId: null } })
2794
+ }
2795
+
2796
+ // 첫 번째 spec 폴더의 spec.md 읽기
2797
+ const firstSpecId = specFolders[0]
2798
+ const specPath = join(specsDir, firstSpecId, 'spec.md')
2799
+ const content = await readFile(specPath, 'utf-8')
2800
+ res.json({ success: true, data: { changeId, content, specId: firstSpecId } })
2801
+ } catch {
2802
+ res.json({ success: true, data: { changeId, content: null, specId: null } })
2803
+ }
2804
+ } catch (error) {
2805
+ console.error('Error reading spec:', error)
2806
+ res.status(500).json({ success: false, error: 'Failed to read spec' })
2807
+ }
2808
+ })
2809
+
2810
+ // GET /api/flow/changes/:changeId/specs/:specId - 특정 spec.md 내용
2811
+ app.get('/api/flow/changes/:changeId/specs/:specId', async (req, res) => {
2812
+ try {
2813
+ const paths = await getProjectPaths()
2814
+ if (!paths) {
2815
+ return res.status(400).json({ success: false, error: 'No active project' })
2816
+ }
2817
+
2818
+ const { changeId, specId } = req.params
2819
+
2820
+ // 1. Change 내 specs 디렉토리 확인
2821
+ const changeSpecPath = join(paths.openspecDir, changeId, 'specs', specId, 'spec.md')
2822
+ try {
2823
+ const content = await readFile(changeSpecPath, 'utf-8')
2824
+ return res.json({ success: true, data: { specId, content, location: 'change' } })
2825
+ } catch {
2826
+ // Change 내에 없으면 archived specs에서 찾기
2827
+ }
2828
+
2829
+ // 2. Archived specs 디렉토리에서 확인
2830
+ const archivedSpecPath = join(paths.specsDir, specId, 'spec.md')
2831
+ try {
2832
+ const content = await readFile(archivedSpecPath, 'utf-8')
2833
+ return res.json({ success: true, data: { specId, content, location: 'archived' } })
2834
+ } catch {
2835
+ return res.json({ success: true, data: { specId, content: null, location: null } })
2836
+ }
2837
+ } catch (error) {
2838
+ console.error('Error reading change spec:', error)
2839
+ res.status(500).json({ success: false, error: 'Failed to read spec' })
2840
+ }
2841
+ })
2842
+
2843
+ // PATCH /api/flow/tasks/:id - Flow Task 수정
2844
+ app.patch('/api/flow/tasks/:id', async (req, res) => {
2845
+ try {
2846
+ await initTaskDb()
2847
+ const { changeId, stage, title, description, status, priority, order } = req.body
2848
+
2849
+ const sqlite = getSqlite()
2850
+ const existing = sqlite.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id)
2851
+
2852
+ if (!existing) {
2853
+ return res.status(404).json({ success: false, error: 'Task not found' })
2854
+ }
2855
+
2856
+ const updates: string[] = []
2857
+ const params: unknown[] = []
2858
+
2859
+ if (changeId !== undefined) {
2860
+ updates.push('change_id = ?')
2861
+ params.push(changeId)
2862
+ }
2863
+ if (stage !== undefined) {
2864
+ updates.push('stage = ?')
2865
+ params.push(stage)
2866
+ }
2867
+ if (title !== undefined) {
2868
+ updates.push('title = ?')
2869
+ params.push(title)
2870
+ }
2871
+ if (description !== undefined) {
2872
+ updates.push('description = ?')
2873
+ params.push(description)
2874
+ }
2875
+ if (status !== undefined) {
2876
+ updates.push('status = ?')
2877
+ params.push(status)
2878
+ }
2879
+ if (priority !== undefined) {
2880
+ updates.push('priority = ?')
2881
+ params.push(priority)
2882
+ }
2883
+ if (order !== undefined) {
2884
+ updates.push('"order" = ?')
2885
+ params.push(order)
2886
+ }
2887
+
2888
+ updates.push('updated_at = ?')
2889
+ params.push(Date.now())
2890
+ params.push(req.params.id)
2891
+
2892
+ sqlite.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...params)
2893
+
2894
+ const task = sqlite.prepare('SELECT * FROM tasks WHERE id = ?').get(req.params.id)
2895
+
2896
+ // WebSocket으로 태스크 업데이트 알림
2897
+ emit('task:updated', { task })
2898
+
2899
+ res.json({ success: true, data: { task } })
2900
+ } catch (error) {
2901
+ console.error('Error updating flow task:', error)
2902
+ res.status(500).json({ success: false, error: 'Failed to update flow task' })
2903
+ }
2904
+ })
2905
+
2906
+ // ==================== OPENSPEC ARCHIVE ====================
2907
+
2908
+ // ==================== PYTHON AGENTS API PROXY ====================
2909
+
2910
+ // Health check for Python agents server
2911
+ app.get('/api/agents/health', async (_req, res) => {
2912
+ try {
2913
+ const response = await fetch('http://localhost:3002/health')
2914
+ if (!response.ok) {
2915
+ return res.status(503).json({
2916
+ success: false,
2917
+ error: 'Python agents server unavailable',
2918
+ pythonStatus: 'offline',
2919
+ })
2920
+ }
2921
+ const data = await response.json()
2922
+ res.json({ success: true, data: { ...data, pythonStatus: 'online' } })
2923
+ } catch {
2924
+ res.json({
2925
+ success: true,
2926
+ data: {
2927
+ status: 'unavailable',
2928
+ pythonStatus: 'offline',
2929
+ message: 'Python agents server is not running. Start with: npm run py:server',
2930
+ },
2931
+ })
2932
+ }
2933
+ })
2934
+
2935
+ // Proxy all /api/agents/* requests to Python server
2936
+ app.use('/api/agents', async (req, res) => {
2937
+ try {
2938
+ // 0. Handle session list request (GET /sessions)
2939
+ const isSessionListRequest = req.path === '/sessions' && req.method === 'GET'
2940
+ if (isSessionListRequest) {
2941
+ const { getProcessManager } = await import('./cli-adapter/process-manager.js')
2942
+ try {
2943
+ const pm = getProcessManager()
2944
+ const sessions = pm.getAllSessions()
2945
+ return res.json(
2946
+ sessions.map((session) => ({
2947
+ session_id: session.id,
2948
+ change_id: session.changeId,
2949
+ status: session.status,
2950
+ created_at: session.startedAt,
2951
+ updated_at: session.endedAt || session.startedAt,
2952
+ project_path: session.projectPath,
2953
+ current_task: null,
2954
+ completed_tasks: 0,
2955
+ total_tasks: 0,
2956
+ error: session.error || null,
2957
+ conversation_history: session.conversationHistory || [],
2958
+ }))
2959
+ )
2960
+ } catch {
2961
+ // ProcessManager not initialized, return empty array
2962
+ return res.json([])
2963
+ }
2964
+ }
2965
+
2966
+ // 0.5. Handle session delete request (DELETE /sessions/:id)
2967
+ const isSessionDeleteRequest = req.path.match(/^\/sessions\/[^/]+$/) && req.method === 'DELETE'
2968
+ if (isSessionDeleteRequest) {
2969
+ const sessionId = req.path.split('/')[2]
2970
+ const { getProcessManager } = await import('./cli-adapter/process-manager.js')
2971
+ try {
2972
+ const pm = getProcessManager()
2973
+ const deleted = pm.deleteSession(sessionId)
2974
+ if (deleted) {
2975
+ return res.json({ success: true })
2976
+ }
2977
+ return res.status(404).json({ success: false, error: 'Session not found' })
2978
+ } catch {
2979
+ return res.status(404).json({ success: false, error: 'Session not found' })
2980
+ }
2981
+ }
2982
+
2983
+ // 1. Check for CLI mode request or Stream request for CLI session
2984
+ const isExecuteRequest = req.path === '/execute' && req.method === 'POST'
2985
+ const isStreamRequest = req.path.match(/\/sessions\/[^/]+\/stream/) && req.method === 'GET'
2986
+
2987
+ // Check if it's a CLI session stream or input request
2988
+ // session input path example: /sessions/UUID/input
2989
+ const isInputRequest = req.path.match(/\/sessions\/[^/]+\/input/) && req.method === 'POST'
2990
+ // session logs path example: /sessions/UUID/logs
2991
+ const isLogsRequest = req.path.match(/\/sessions\/[^/]+\/logs/) && req.method === 'GET'
2992
+ // session status path example: /sessions/UUID (exact match, no trailing path)
2993
+ const isSessionStatusRequest = req.path.match(/^\/sessions\/[^/]+$/) && req.method === 'GET'
2994
+
2995
+ let isCliSession = false
2996
+ let targetSessionId: string | undefined
2997
+
2998
+ // Check if any session-related request is for a CLI session
2999
+ if (isStreamRequest || isInputRequest || isLogsRequest || isSessionStatusRequest) {
3000
+ targetSessionId = req.path.split('/')[2]
3001
+
3002
+ const { getProcessManager } = await import('./cli-adapter/process-manager.js')
3003
+ try {
3004
+ const pm = getProcessManager()
3005
+ const session = pm.getSession(targetSessionId)
3006
+
3007
+ if (session) {
3008
+ isCliSession = true
3009
+ }
3010
+ } catch {
3011
+ // ProcessManager not initialized, fall through to Python server
3012
+ }
3013
+ }
3014
+
3015
+ // Handle CLI Session Status request
3016
+ if (isSessionStatusRequest && isCliSession && targetSessionId) {
3017
+ const { getProcessManager } = await import('./cli-adapter/process-manager.js')
3018
+ const pm = getProcessManager()
3019
+ const session = pm.getSession(targetSessionId)
3020
+
3021
+ if (session) {
3022
+ return res.json({
3023
+ session_id: session.id,
3024
+ change_id: session.changeId,
3025
+ status: session.status,
3026
+ created_at: session.startedAt,
3027
+ updated_at: session.endedAt || session.startedAt,
3028
+ project_path: session.projectPath,
3029
+ current_task: null,
3030
+ completed_tasks: 0,
3031
+ total_tasks: 0,
3032
+ error: session.error || null,
3033
+ // Include conversation history for message persistence
3034
+ conversation_history: session.conversationHistory || [],
3035
+ })
3036
+ }
3037
+ }
3038
+
3039
+ // 2. Handle CLI Execution
3040
+ // 2. Handle CLI Execution
3041
+ if (isExecuteRequest && req.body.use_cli === true) {
3042
+ console.log('[Proxy] Routing to CLI Adapter (Execute)')
3043
+ const { initProcessManager, getProcessManager } =
3044
+ await import('./cli-adapter/process-manager.js')
3045
+ const projectPath = req.body.project_path || process.cwd()
3046
+
3047
+ let processManager
3048
+ try {
3049
+ processManager = getProcessManager(projectPath)
3050
+ } catch {
3051
+ processManager = initProcessManager(projectPath)
3052
+ }
3053
+
3054
+ const result = await processManager.start({
3055
+ profileId: 'claude', // Default to claude
3056
+ changeId: req.body.change_id,
3057
+ projectPath,
3058
+ initialPrompt: req.body.initial_prompt,
3059
+ })
3060
+
3061
+ if (!result.success) {
3062
+ return res.status(500).json({ success: false, error: result.error })
3063
+ }
3064
+
3065
+ return res.json({
3066
+ session_id: result.sessionId,
3067
+ status: 'running',
3068
+ message: 'CLI Session started via Adapter',
3069
+ change_id: req.body.change_id || 'unknown',
3070
+ created_at: new Date().toISOString(),
3071
+ updated_at: new Date().toISOString(),
3072
+ project_path: projectPath,
3073
+ })
3074
+ }
3075
+
3076
+ // 3. Handle CLI Input
3077
+ if (isInputRequest && isCliSession && targetSessionId) {
3078
+ const { getProcessManager } = await import('./cli-adapter/process-manager.js')
3079
+ const processManager = getProcessManager()
3080
+
3081
+ const result = await processManager.sendInput(targetSessionId, req.body.input || '')
3082
+ if (!result.success) {
3083
+ return res.status(500).json({ success: false, error: result.error })
3084
+ }
3085
+ return res.json({ success: true })
3086
+ }
3087
+
3088
+ // 4. Handle CLI Stream
3089
+ if (isCliSession) {
3090
+ const sessionId = targetSessionId || req.path.split('/')[2]
3091
+ const { getProcessManager } = await import('./cli-adapter/process-manager.js')
3092
+ const processManager = getProcessManager()
3093
+
3094
+ res.setHeader('Content-Type', 'text/event-stream')
3095
+ res.setHeader('Cache-Control', 'no-cache')
3096
+ res.setHeader('Connection', 'keep-alive')
3097
+
3098
+ const sendEvent = (type: string, data: Record<string, unknown>) => {
3099
+ if (res.writableEnded || !res.writable) return
3100
+ try {
3101
+ res.write(`event: message\ndata: ${JSON.stringify({ type, ...data })}\n\n`)
3102
+ } catch (e) {
3103
+ console.error('[SSE] Error sending event:', e)
3104
+ }
3105
+ }
3106
+
3107
+ // Convert CLI output to Agent events
3108
+ const onOutput = (output: { sessionId: string; content: string; timestamp: string }) => {
3109
+ if (output.sessionId === sessionId) {
3110
+ // Map stdout to agent_response or task_complete based on content?
3111
+ // For now just stream raw output as agent_response
3112
+ sendEvent('agent_response', {
3113
+ content: output.content,
3114
+ timestamp: output.timestamp,
3115
+ })
3116
+ }
3117
+ }
3118
+
3119
+ const onEnd = (session: { id: string }) => {
3120
+ if (session.id === sessionId) {
3121
+ sendEvent('session_end', { timestamp: new Date().toISOString() })
3122
+ if (!res.writableEnded) {
3123
+ res.end()
3124
+ }
3125
+ processManager.off('output', onOutput)
3126
+ processManager.off('session:end', onEnd)
3127
+ }
3128
+ }
3129
+
3130
+ // 만약 이미 종료된 세션이라면?
3131
+ const session = processManager.getSession(sessionId)
3132
+ if (session && (session.status === 'completed' || session.status === 'failed')) {
3133
+ // 이미 종료된 세션의 로그를 한꺼번에 보내주거나 종료 처리
3134
+ onEnd(session)
3135
+ return
3136
+ }
3137
+
3138
+ processManager.on('output', onOutput)
3139
+ processManager.on('session:end', onEnd)
3140
+
3141
+ req.on('close', () => {
3142
+ processManager.off('output', onOutput)
3143
+ processManager.off('session:end', onEnd)
3144
+ })
3145
+
3146
+ return
3147
+ }
3148
+
3149
+ // 4. Fallback to Python Server (Normal Flow)
3150
+ const targetUrl = `http://localhost:3002${req.originalUrl}`
3151
+
3152
+ const fetchOptions: RequestInit = {
3153
+ method: req.method,
3154
+ headers: {
3155
+ 'Content-Type': 'application/json',
3156
+ },
3157
+ }
3158
+
3159
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
3160
+ fetchOptions.body = JSON.stringify(req.body)
3161
+ }
3162
+
3163
+ const response = await fetch(targetUrl, fetchOptions)
3164
+
3165
+ // Handle SSE streams
3166
+ if (response.headers.get('content-type')?.includes('text/event-stream')) {
3167
+ res.setHeader('Content-Type', 'text/event-stream')
3168
+ res.setHeader('Cache-Control', 'no-cache')
3169
+ res.setHeader('Connection', 'keep-alive')
3170
+
3171
+ const reader = response.body?.getReader()
3172
+ if (reader) {
3173
+ const decoder = new TextDecoder()
3174
+ while (true) {
3175
+ const { done, value } = await reader.read()
3176
+ if (done) break
3177
+ res.write(decoder.decode(value))
3178
+ }
3179
+ res.end()
3180
+ }
3181
+ return
3182
+ }
3183
+
3184
+ const data = await response.json()
3185
+ res.status(response.status).json(data)
3186
+ } catch (error) {
3187
+ console.error('Error proxying to Python agents server:', error)
3188
+
3189
+ // Auto-fallback to CLI if Python server is down for execute requests?
3190
+ // For now, just return error with hint
3191
+ res.status(503).json({
3192
+ success: false,
3193
+ error: 'Failed to connect to Python agents server',
3194
+ hint: 'Start with: npm run py:server',
3195
+ })
3196
+ }
3197
+ })
3198
+
3199
+ // ==================== OPENSPEC ARCHIVE ====================
3200
+
3201
+ // POST /api/flow/changes/:id/archive - Change를 아카이브로 이동
3202
+ app.post('/api/flow/changes/:id/archive', async (req, res) => {
3203
+ try {
3204
+ const changeId = req.params.id
3205
+ const { skipSpecs, force } = req.body
3206
+ const project = await getActiveProject()
3207
+
3208
+ if (!project) {
3209
+ return res.status(400).json({ success: false, error: 'No active project' })
3210
+ }
3211
+
3212
+ // openspec archive 명령어 실행
3213
+ const args = ['archive', changeId, '-y']
3214
+ if (skipSpecs) {
3215
+ args.push('--skip-specs')
3216
+ }
3217
+ if (force) {
3218
+ args.push('--no-validate')
3219
+ }
3220
+
3221
+ let stdout = ''
3222
+ let stderr = ''
3223
+ let validationFailed = false
3224
+ const validationErrors: string[] = []
3225
+
3226
+ try {
3227
+ const result = await execAsync(`openspec ${args.join(' ')}`, {
3228
+ cwd: project.path,
3229
+ })
3230
+ stdout = result.stdout
3231
+ stderr = result.stderr
3232
+ } catch (execError) {
3233
+ // execAsync throws on non-zero exit code
3234
+ const error = execError as { stdout?: string; stderr?: string; message?: string }
3235
+ stdout = error.stdout || ''
3236
+ stderr = error.stderr || ''
3237
+
3238
+ // Check if it's a validation error
3239
+ if (stdout.includes('Validation failed') || stdout.includes('Validation errors')) {
3240
+ validationFailed = true
3241
+ // Parse validation errors from output
3242
+ const lines = stdout.split('\n')
3243
+ for (const line of lines) {
3244
+ if (line.includes('✗') || line.includes('⚠')) {
3245
+ validationErrors.push(line.trim())
3246
+ }
3247
+ }
3248
+ } else {
3249
+ // Other error, rethrow
3250
+ throw execError
3251
+ }
3252
+ }
3253
+
3254
+ // If validation failed and not forced, return error to let user decide
3255
+ if (validationFailed && !force) {
3256
+ console.log(`[Archive] Validation failed for ${changeId}, returning error to client`)
3257
+ return res.status(422).json({
3258
+ success: false,
3259
+ error: 'Validation failed',
3260
+ validationErrors,
3261
+ canForce: true,
3262
+ hint: 'Use force option to archive without validation, or fix the spec errors first',
3263
+ })
3264
+ }
3265
+
3266
+ // Check if archive directory was actually created (file was moved)
3267
+ const archivePath = join(project.path, 'openspec', 'changes', 'archive', changeId)
3268
+ const originalPath = join(project.path, 'openspec', 'changes', changeId)
3269
+
3270
+ let filesMoved = false
3271
+ try {
3272
+ await import('fs/promises').then((fs) => fs.access(archivePath))
3273
+ filesMoved = true
3274
+ } catch {
3275
+ // Archive directory doesn't exist, check if original still exists
3276
+ try {
3277
+ await import('fs/promises').then((fs) => fs.access(originalPath))
3278
+ filesMoved = false
3279
+ } catch {
3280
+ // Neither exists - something went wrong
3281
+ filesMoved = false
3282
+ }
3283
+ }
3284
+
3285
+ // DB에서 Change 상태 업데이트 (파일 이동 여부와 관계없이)
3286
+ await initTaskDb()
3287
+ const sqlite = getSqlite()
3288
+ const now = Date.now()
3289
+
3290
+ sqlite
3291
+ .prepare(
3292
+ `
3293
+ UPDATE changes SET status = 'archived', updated_at = ? WHERE id = ? AND project_id = ?
3294
+ `
3295
+ )
3296
+ .run(now, changeId, project.id)
3297
+
3298
+ // WebSocket으로 아카이브 완료 알림
3299
+ emit('change:archived', { changeId, projectId: project.id })
3300
+
3301
+ res.json({
3302
+ success: true,
3303
+ data: {
3304
+ changeId,
3305
+ archived: true,
3306
+ filesMoved,
3307
+ stdout: stdout.trim(),
3308
+ stderr: stderr.trim(),
3309
+ },
3310
+ })
3311
+ } catch (error) {
3312
+ console.error('Error archiving change:', error)
3313
+ res.status(500).json({
3314
+ success: false,
3315
+ error: error instanceof Error ? error.message : 'Failed to archive change',
3316
+ })
3317
+ }
3318
+ })
3319
+
3320
+ // POST /api/flow/changes/:id/fix-validation - 자동으로 validation 에러 수정
3321
+ app.post('/api/flow/changes/:id/fix-validation', async (req, res) => {
3322
+ try {
3323
+ const changeId = req.params.id
3324
+ const project = await getActiveProject()
3325
+
3326
+ if (!project) {
3327
+ return res.status(400).json({ success: false, error: 'No active project' })
3328
+ }
3329
+
3330
+ const changeDir = join(project.path, 'openspec', 'changes', changeId)
3331
+ const fs = await import('fs/promises')
3332
+
3333
+ // Fix proposal.md - add "SHALL" to requirements that don't have it
3334
+ const proposalPath = join(changeDir, 'proposal.md')
3335
+ let proposalFixed = false
3336
+ try {
3337
+ const content = await fs.readFile(proposalPath, 'utf-8')
3338
+ // Find requirement lines without SHALL/MUST and add "SHALL"
3339
+ // Pattern: Lines starting with "- " in requirements section that don't have SHALL/MUST
3340
+ const lines = content.split('\n')
3341
+ const fixedLines = lines.map((line) => {
3342
+ // Check if it's a requirement-like line (starts with "- " and doesn't have SHALL/MUST)
3343
+ if (line.match(/^[-*]\s+/) && !line.match(/\b(SHALL|MUST)\b/i)) {
3344
+ // Check if it's in a requirements context (has keywords like "requirement", "feature", etc.)
3345
+ if (line.match(/^[-*]\s+\w/)) {
3346
+ // Insert "SHALL" after the bullet point
3347
+ return line.replace(/^([-*]\s+)/, '$1The system SHALL ')
3348
+ }
3349
+ }
3350
+ return line
3351
+ })
3352
+ const newContent = fixedLines.join('\n')
3353
+ if (newContent !== content) {
3354
+ await fs.writeFile(proposalPath, newContent)
3355
+ proposalFixed = true
3356
+ }
3357
+ } catch {
3358
+ // proposal.md not found or not readable
3359
+ }
3360
+
3361
+ // Fix spec files in specs/ subdirectory
3362
+ const specsDir = join(changeDir, 'specs')
3363
+ let specsFixed = 0
3364
+ try {
3365
+ const specEntries = await fs.readdir(specsDir, { withFileTypes: true })
3366
+ for (const entry of specEntries) {
3367
+ if (!entry.isDirectory()) continue
3368
+ const specPath = join(specsDir, entry.name, 'spec.md')
3369
+ try {
3370
+ const content = await fs.readFile(specPath, 'utf-8')
3371
+ // Find requirement headings and their content
3372
+ // Pattern: ### Requirement: ... followed by content
3373
+ const lines = content.split('\n')
3374
+ const fixedLines = lines.map((line, _i) => {
3375
+ // If this is a requirement heading
3376
+ if (line.match(/^###\s+Requirement:/)) {
3377
+ // Check if the requirement text contains SHALL/MUST
3378
+ if (!line.match(/\b(SHALL|MUST)\b/i)) {
3379
+ // Insert "SHALL" into the requirement
3380
+ return line.replace(/(###\s+Requirement:\s*)(.+)/, (_, prefix, reqText) => {
3381
+ // If the text is like "Feature Name", convert to "The system SHALL provide Feature Name"
3382
+ return `${prefix}The system SHALL provide ${reqText}`
3383
+ })
3384
+ }
3385
+ }
3386
+ return line
3387
+ })
3388
+ const newContent = fixedLines.join('\n')
3389
+ if (newContent !== content) {
3390
+ await fs.writeFile(specPath, newContent)
3391
+ specsFixed++
3392
+ }
3393
+ } catch {
3394
+ // spec.md not found
3395
+ }
3396
+ }
3397
+ } catch {
3398
+ // specs directory not found
3399
+ }
3400
+
3401
+ res.json({
3402
+ success: true,
3403
+ data: {
3404
+ changeId,
3405
+ proposalFixed,
3406
+ specsFixed,
3407
+ message:
3408
+ proposalFixed || specsFixed > 0
3409
+ ? `Fixed validation errors: proposal=${proposalFixed}, specs=${specsFixed}`
3410
+ : 'No fixes needed or possible',
3411
+ },
3412
+ })
3413
+ } catch (error) {
3414
+ console.error('Error fixing validation:', error)
3415
+ res.status(500).json({
3416
+ success: false,
3417
+ error: error instanceof Error ? error.message : 'Failed to fix validation',
3418
+ })
3419
+ }
3420
+ })
3421
+
3422
+ // ==================== DIAGRAM API ====================
3423
+
3424
+ // GET /api/diagram/context - Get file tree and README for diagram generation
3425
+ app.get('/api/diagram/context', async (req, res) => {
3426
+ try {
3427
+ const project = await getActiveProject()
3428
+ if (!project) {
3429
+ return res.status(400).json({ success: false, error: 'No active project' })
3430
+ }
3431
+
3432
+ const projectPath = (req.query.path as string) || project.path
3433
+ const maxDepth = parseInt(req.query.maxDepth as string) || 10
3434
+
3435
+ const core = await getGitdiagramCore()
3436
+ const [fileTree, readme] = await Promise.all([
3437
+ core.generateFileTree(projectPath, { maxDepth }),
3438
+ core.readReadme(projectPath),
3439
+ ])
3440
+
3441
+ res.json({
3442
+ success: true,
3443
+ data: {
3444
+ projectPath,
3445
+ fileTree,
3446
+ readme,
3447
+ fileTreeLines: fileTree.split('\n').length,
3448
+ },
3449
+ })
3450
+ } catch (error) {
3451
+ console.error('Error getting diagram context:', error)
3452
+ res.status(500).json({
3453
+ success: false,
3454
+ error: error instanceof Error ? error.message : 'Failed to get diagram context',
3455
+ })
3456
+ }
3457
+ })
3458
+
3459
+ // POST /api/diagram/generate - Generate diagram using LLM
3460
+ app.post('/api/diagram/generate', async (req, res) => {
3461
+ try {
3462
+ const project = await getActiveProject()
3463
+ if (!project) {
3464
+ return res.status(400).json({ success: false, error: 'No active project' })
3465
+ }
3466
+
3467
+ const { instructions } = req.body
3468
+ const projectPath = req.body.projectPath || project.path
3469
+
3470
+ // Get gitdiagram-core functions
3471
+ const core = await getGitdiagramCore()
3472
+
3473
+ // Check for API key - priority: Gemini > Claude > OpenAI (Gemini first as it's commonly available)
3474
+ let apiKey: string | undefined
3475
+ let provider: 'claude' | 'openai' | 'gemini'
3476
+
3477
+ // Debug log for environment variables
3478
+ console.log('[Diagram] Available API keys:', {
3479
+ anthropic: !!process.env.ANTHROPIC_API_KEY,
3480
+ openai: !!process.env.OPENAI_API_KEY,
3481
+ google: !!process.env.GOOGLE_API_KEY,
3482
+ gemini: !!process.env.GEMINI_API_KEY,
3483
+ })
3484
+
3485
+ if (process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY) {
3486
+ apiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY
3487
+ provider = 'gemini'
3488
+ } else if (process.env.ANTHROPIC_API_KEY) {
3489
+ apiKey = process.env.ANTHROPIC_API_KEY
3490
+ provider = 'claude'
3491
+ } else if (process.env.OPENAI_API_KEY) {
3492
+ apiKey = process.env.OPENAI_API_KEY
3493
+ provider = 'openai'
3494
+ } else {
3495
+ // Return a generated diagram based on file structure analysis (no LLM)
3496
+ const [fileTree] = await Promise.all([core.generateFileTree(projectPath, { maxDepth: 8 })])
3497
+ const simpleDiagram = generateSimpleDiagram(fileTree, project.name)
3498
+ return res.json({
3499
+ success: true,
3500
+ data: {
3501
+ mermaidCode: simpleDiagram,
3502
+ projectPath,
3503
+ generated: 'simple', // Indicates no LLM was used
3504
+ message: 'Generated simple diagram (no LLM API key configured)',
3505
+ },
3506
+ })
3507
+ }
3508
+
3509
+ // Use LLM to generate diagram
3510
+ console.log(`[Diagram] Using provider: ${provider}`)
3511
+ const adapter = core.createLLMAdapter(provider, { apiKey })
3512
+
3513
+ const result = await core.generateDiagram(projectPath, {
3514
+ llm: adapter,
3515
+ instructions,
3516
+ })
3517
+
3518
+ res.json({
3519
+ success: true,
3520
+ data: {
3521
+ mermaidCode: result.mermaidCode,
3522
+ projectPath,
3523
+ generated: 'llm',
3524
+ explanation: result.explanation,
3525
+ },
3526
+ })
3527
+ } catch (error) {
3528
+ console.error('Error generating diagram:', error)
3529
+ res.status(500).json({
3530
+ success: false,
3531
+ error: error instanceof Error ? error.message : 'Failed to generate diagram',
3532
+ })
3533
+ }
3534
+ })
3535
+
3536
+ // Helper function to generate simple diagram from file tree (no LLM)
3537
+ function generateSimpleDiagram(fileTree: string, projectName: string): string {
3538
+ const lines = fileTree.split('\n').filter((l) => l.trim())
3539
+ const dirs = new Set<string>()
3540
+ const files: string[] = []
3541
+
3542
+ // Parse top-level directories and key files
3543
+ for (const line of lines) {
3544
+ const depth = (line.match(/^[│├└─\s]*/)?.[0] || '').length / 4
3545
+ const name = line.replace(/^[│├└─\s]+/, '').trim()
3546
+
3547
+ if (depth === 0 && name && !name.startsWith('.')) {
3548
+ if (!name.includes('.')) {
3549
+ dirs.add(name)
3550
+ } else if (name.endsWith('.ts') || name.endsWith('.tsx') || name.endsWith('.json')) {
3551
+ files.push(name)
3552
+ }
3553
+ }
3554
+ }
3555
+
3556
+ // Build diagram
3557
+ let diagram = `flowchart TD
3558
+ subgraph Project["${projectName}"]
3559
+ `
3560
+
3561
+ const nodeIds: string[] = []
3562
+
3563
+ // Add directories
3564
+ for (const dir of dirs) {
3565
+ const nodeId = dir.replace(/[^a-zA-Z0-9]/g, '')
3566
+ nodeIds.push(nodeId)
3567
+ diagram += ` ${nodeId}[📁 ${dir}]\n`
3568
+ }
3569
+
3570
+ // Add key files
3571
+ for (const file of files.slice(0, 5)) {
3572
+ const nodeId = file.replace(/[^a-zA-Z0-9]/g, '')
3573
+ nodeIds.push(nodeId)
3574
+ diagram += ` ${nodeId}[📄 ${file}]\n`
3575
+ }
3576
+
3577
+ diagram += ` end\n`
3578
+
3579
+ // Add some connections based on common patterns
3580
+ if (nodeIds.includes('src') && nodeIds.includes('server')) {
3581
+ diagram += ` src --> server\n`
3582
+ }
3583
+ if (nodeIds.includes('src') && nodeIds.includes('components')) {
3584
+ diagram += ` src --> components\n`
3585
+ }
3586
+ if (nodeIds.includes('server') && nodeIds.includes('api')) {
3587
+ diagram += ` server --> api\n`
3588
+ }
3589
+
3590
+ // Add styles
3591
+ diagram += `
3592
+ style Project fill:#f5f5f5,stroke:#333
3593
+ `
3594
+
3595
+ return diagram
3596
+ }
3597
+
3598
+ // POST /api/diagram/validate - Validate Mermaid syntax
3599
+ app.post('/api/diagram/validate', async (req, res) => {
3600
+ try {
3601
+ const { code } = req.body
3602
+ if (!code) {
3603
+ return res.status(400).json({ success: false, error: 'No code provided' })
3604
+ }
3605
+
3606
+ const core = await getGitdiagramCore()
3607
+ const validation = core.validateMermaidSyntax(code)
3608
+ const clickEvents = core.extractClickEvents(code)
3609
+
3610
+ res.json({
3611
+ success: true,
3612
+ data: {
3613
+ valid: validation.valid,
3614
+ errors: validation.errors,
3615
+ warnings: validation.warnings,
3616
+ clickEvents,
3617
+ },
3618
+ })
3619
+ } catch (error) {
3620
+ console.error('Error validating diagram:', error)
3621
+ res.status(500).json({
3622
+ success: false,
3623
+ error: error instanceof Error ? error.message : 'Failed to validate diagram',
3624
+ })
3625
+ }
3626
+ })
3627
+
3628
+ // GET /api/diagram/change/:changeId - Get diagram context for an OpenSpec change
3629
+ app.get('/api/diagram/change/:changeId', async (req, res) => {
3630
+ try {
3631
+ const { changeId } = req.params
3632
+ const project = await getActiveProject()
3633
+
3634
+ if (!project) {
3635
+ return res.status(400).json({ success: false, error: 'No active project' })
3636
+ }
3637
+
3638
+ const changeDir = join(project.path, 'openspec', 'changes', changeId)
3639
+
3640
+ // Read change documents
3641
+ let proposal = ''
3642
+ let spec = ''
3643
+ let tasks = ''
3644
+
3645
+ try {
3646
+ proposal = await readFile(join(changeDir, 'proposal.md'), 'utf-8')
3647
+ } catch {
3648
+ /* no proposal */
3649
+ }
3650
+
3651
+ try {
3652
+ spec = await readFile(join(changeDir, 'spec.md'), 'utf-8')
3653
+ } catch {
3654
+ /* no spec */
3655
+ }
3656
+
3657
+ try {
3658
+ tasks = await readFile(join(changeDir, 'tasks.md'), 'utf-8')
3659
+ } catch {
3660
+ /* no tasks */
3661
+ }
3662
+
3663
+ // Extract affected files
3664
+ const affectedFiles: string[] = []
3665
+ const filePattern = /`([^`]+\.(ts|tsx|js|jsx|py|go|rs|java|md))`/g
3666
+
3667
+ for (const content of [proposal, spec, tasks]) {
3668
+ let match
3669
+ while ((match = filePattern.exec(content)) !== null) {
3670
+ if (!affectedFiles.includes(match[1])) {
3671
+ affectedFiles.push(match[1])
3672
+ }
3673
+ }
3674
+ }
3675
+
3676
+ res.json({
3677
+ success: true,
3678
+ data: {
3679
+ changeId,
3680
+ affectedFiles,
3681
+ hasProposal: !!proposal,
3682
+ hasSpec: !!spec,
3683
+ hasTasks: !!tasks,
3684
+ },
3685
+ })
3686
+ } catch (error) {
3687
+ console.error('Error getting change diagram context:', error)
3688
+ res.status(500).json({
3689
+ success: false,
3690
+ error: error instanceof Error ? error.message : 'Failed to get change context',
3691
+ })
3692
+ }
3693
+ })