orbital-command 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (380) hide show
  1. package/bin/orbital.js +640 -37
  2. package/dist/assets/PrimitivesConfig-CrmQXYh4.js +32 -0
  3. package/dist/assets/QualityGates-BbasOsF3.js +21 -0
  4. package/dist/assets/SessionTimeline-CGeJsVvy.js +1 -0
  5. package/dist/assets/Settings-oiM496mc.js +12 -0
  6. package/dist/assets/SourceControl-B1fP2nJL.js +41 -0
  7. package/dist/assets/WorkflowVisualizer-CWLYf-f0.js +74 -0
  8. package/dist/assets/arrow-down-CPy85_J6.js +6 -0
  9. package/dist/assets/charts-DbDg0Psc.js +68 -0
  10. package/dist/assets/circle-x-Cwz6ZQDV.js +6 -0
  11. package/dist/assets/file-text-C46Xr65c.js +6 -0
  12. package/dist/assets/formatDistanceToNow-BMqsSP44.js +1 -0
  13. package/dist/assets/globe-Cn2yNZUD.js +6 -0
  14. package/dist/assets/index-Aj4sV8Al.css +1 -0
  15. package/dist/assets/index-Bc9dK3MW.js +354 -0
  16. package/dist/assets/key-OPaNTWJ5.js +6 -0
  17. package/dist/assets/minus-GMsbpKym.js +6 -0
  18. package/dist/assets/shield-DwAFkDYI.js +6 -0
  19. package/dist/assets/ui-BmsSg9jU.js +53 -0
  20. package/dist/assets/useWorkflowEditor-BJkTX_NR.js +16 -0
  21. package/dist/assets/{vendor-Dzv9lrRc.js → vendor-Bqt8AJn2.js} +1 -1
  22. package/dist/assets/zap-DfbUoOty.js +11 -0
  23. package/dist/favicon.svg +1 -0
  24. package/dist/index.html +6 -5
  25. package/dist/server/server/__tests__/data-routes.test.js +124 -0
  26. package/dist/server/server/__tests__/helpers/db.js +17 -0
  27. package/dist/server/server/__tests__/helpers/mock-emitter.js +8 -0
  28. package/dist/server/server/__tests__/scope-routes.test.js +137 -0
  29. package/dist/server/server/__tests__/sprint-routes.test.js +102 -0
  30. package/dist/server/server/__tests__/workflow-routes.test.js +107 -0
  31. package/dist/server/server/config-migrator.js +138 -0
  32. package/dist/server/server/config.js +17 -2
  33. package/dist/server/server/database.js +27 -12
  34. package/dist/server/server/global-config.js +143 -0
  35. package/dist/server/server/index.js +882 -252
  36. package/dist/server/server/init.js +579 -194
  37. package/dist/server/server/launch.js +29 -0
  38. package/dist/server/server/manifest-types.js +8 -0
  39. package/dist/server/server/manifest.js +454 -0
  40. package/dist/server/server/migrate-legacy.js +229 -0
  41. package/dist/server/server/parsers/event-parser.test.js +117 -0
  42. package/dist/server/server/parsers/scope-parser.js +74 -28
  43. package/dist/server/server/parsers/scope-parser.test.js +230 -0
  44. package/dist/server/server/project-context.js +255 -0
  45. package/dist/server/server/project-emitter.js +41 -0
  46. package/dist/server/server/project-manager.js +297 -0
  47. package/dist/server/server/routes/config-routes.js +1 -3
  48. package/dist/server/server/routes/data-routes.js +22 -110
  49. package/dist/server/server/routes/dispatch-routes.js +15 -9
  50. package/dist/server/server/routes/git-routes.js +74 -0
  51. package/dist/server/server/routes/manifest-routes.js +319 -0
  52. package/dist/server/server/routes/scope-routes.js +37 -23
  53. package/dist/server/server/routes/sync-routes.js +134 -0
  54. package/dist/server/server/routes/version-routes.js +1 -15
  55. package/dist/server/server/routes/workflow-routes.js +9 -3
  56. package/dist/server/server/schema.js +2 -0
  57. package/dist/server/server/services/batch-orchestrator.js +26 -16
  58. package/dist/server/server/services/claude-session-service.js +17 -14
  59. package/dist/server/server/services/deploy-service.test.js +119 -0
  60. package/dist/server/server/services/event-service.js +64 -1
  61. package/dist/server/server/services/event-service.test.js +191 -0
  62. package/dist/server/server/services/gate-service.test.js +105 -0
  63. package/dist/server/server/services/git-service.js +108 -4
  64. package/dist/server/server/services/github-service.js +110 -2
  65. package/dist/server/server/services/readiness-service.test.js +190 -0
  66. package/dist/server/server/services/scope-cache.js +5 -1
  67. package/dist/server/server/services/scope-cache.test.js +142 -0
  68. package/dist/server/server/services/scope-service.js +217 -126
  69. package/dist/server/server/services/scope-service.test.js +137 -0
  70. package/dist/server/server/services/sprint-orchestrator.js +7 -6
  71. package/dist/server/server/services/sprint-service.js +21 -1
  72. package/dist/server/server/services/sprint-service.test.js +238 -0
  73. package/dist/server/server/services/sync-service.js +434 -0
  74. package/dist/server/server/services/sync-types.js +2 -0
  75. package/dist/server/server/services/telemetry-service.js +143 -0
  76. package/dist/server/server/services/workflow-service.js +26 -5
  77. package/dist/server/server/services/workflow-service.test.js +159 -0
  78. package/dist/server/server/settings-sync.js +284 -0
  79. package/dist/server/server/update-planner.js +279 -0
  80. package/dist/server/server/utils/cc-hooks-parser.js +3 -0
  81. package/dist/server/server/utils/cc-hooks-parser.test.js +86 -0
  82. package/dist/server/server/utils/dispatch-utils.js +77 -20
  83. package/dist/server/server/utils/dispatch-utils.test.js +182 -0
  84. package/dist/server/server/utils/logger.js +37 -3
  85. package/dist/server/server/utils/package-info.js +30 -0
  86. package/dist/server/server/utils/route-helpers.js +10 -0
  87. package/dist/server/server/utils/terminal-launcher.js +79 -25
  88. package/dist/server/server/utils/worktree-manager.js +13 -4
  89. package/dist/server/server/validator.js +230 -0
  90. package/dist/server/server/watchers/global-watcher.js +63 -0
  91. package/dist/server/server/watchers/scope-watcher.js +27 -12
  92. package/dist/server/server/wizard/config-editor.js +237 -0
  93. package/dist/server/server/wizard/detect.js +96 -0
  94. package/dist/server/server/wizard/doctor.js +115 -0
  95. package/dist/server/server/wizard/index.js +155 -0
  96. package/dist/server/server/wizard/phases/confirm.js +39 -0
  97. package/dist/server/server/wizard/phases/project-setup.js +90 -0
  98. package/dist/server/server/wizard/phases/setup-wizard.js +66 -0
  99. package/dist/server/server/wizard/phases/welcome.js +35 -0
  100. package/dist/server/server/wizard/phases/workflow-setup.js +22 -0
  101. package/dist/server/server/wizard/types.js +29 -0
  102. package/dist/server/server/wizard/ui.js +74 -0
  103. package/dist/server/shared/__fixtures__/workflow-configs.js +75 -0
  104. package/dist/server/shared/default-workflow.json +65 -0
  105. package/dist/server/shared/onboarding-tour.test.js +81 -0
  106. package/dist/server/shared/project-colors.js +24 -0
  107. package/dist/server/shared/workflow-config.test.js +84 -0
  108. package/dist/server/shared/workflow-engine.test.js +302 -0
  109. package/dist/server/shared/workflow-normalizer.js +101 -0
  110. package/dist/server/shared/workflow-normalizer.test.js +100 -0
  111. package/dist/server/src/components/onboarding/tour-steps.js +84 -0
  112. package/package.json +20 -15
  113. package/schemas/orbital.config.schema.json +16 -1
  114. package/scripts/postinstall.js +55 -7
  115. package/server/__tests__/data-routes.test.ts +149 -0
  116. package/server/__tests__/helpers/db.ts +19 -0
  117. package/server/__tests__/helpers/mock-emitter.ts +10 -0
  118. package/server/__tests__/scope-routes.test.ts +157 -0
  119. package/server/__tests__/sprint-routes.test.ts +118 -0
  120. package/server/__tests__/workflow-routes.test.ts +120 -0
  121. package/server/config-migrator.ts +163 -0
  122. package/server/config.ts +26 -2
  123. package/server/database.ts +35 -18
  124. package/server/global-config.ts +200 -0
  125. package/server/index.ts +975 -287
  126. package/server/init.ts +625 -182
  127. package/server/launch.ts +32 -0
  128. package/server/manifest-types.ts +145 -0
  129. package/server/manifest.ts +494 -0
  130. package/server/migrate-legacy.ts +290 -0
  131. package/server/parsers/event-parser.test.ts +135 -0
  132. package/server/parsers/scope-parser.test.ts +270 -0
  133. package/server/parsers/scope-parser.ts +79 -31
  134. package/server/project-context.ts +309 -0
  135. package/server/project-emitter.ts +50 -0
  136. package/server/project-manager.ts +369 -0
  137. package/server/routes/config-routes.ts +3 -5
  138. package/server/routes/data-routes.ts +28 -141
  139. package/server/routes/dispatch-routes.ts +19 -11
  140. package/server/routes/git-routes.ts +77 -0
  141. package/server/routes/manifest-routes.ts +388 -0
  142. package/server/routes/scope-routes.ts +29 -25
  143. package/server/routes/sync-routes.ts +175 -0
  144. package/server/routes/version-routes.ts +1 -16
  145. package/server/routes/workflow-routes.ts +9 -3
  146. package/server/schema.ts +2 -0
  147. package/server/services/batch-orchestrator.ts +24 -16
  148. package/server/services/claude-session-service.ts +16 -14
  149. package/server/services/deploy-service.test.ts +145 -0
  150. package/server/services/deploy-service.ts +2 -2
  151. package/server/services/event-service.test.ts +242 -0
  152. package/server/services/event-service.ts +92 -3
  153. package/server/services/gate-service.test.ts +131 -0
  154. package/server/services/gate-service.ts +2 -2
  155. package/server/services/git-service.ts +137 -4
  156. package/server/services/github-service.ts +120 -2
  157. package/server/services/readiness-service.test.ts +217 -0
  158. package/server/services/scope-cache.test.ts +167 -0
  159. package/server/services/scope-cache.ts +4 -1
  160. package/server/services/scope-service.test.ts +169 -0
  161. package/server/services/scope-service.ts +220 -126
  162. package/server/services/sprint-orchestrator.ts +7 -7
  163. package/server/services/sprint-service.test.ts +271 -0
  164. package/server/services/sprint-service.ts +27 -3
  165. package/server/services/sync-service.ts +482 -0
  166. package/server/services/sync-types.ts +77 -0
  167. package/server/services/telemetry-service.ts +195 -0
  168. package/server/services/workflow-service.test.ts +190 -0
  169. package/server/services/workflow-service.ts +29 -9
  170. package/server/settings-sync.ts +359 -0
  171. package/server/update-planner.ts +346 -0
  172. package/server/utils/cc-hooks-parser.test.ts +96 -0
  173. package/server/utils/cc-hooks-parser.ts +4 -0
  174. package/server/utils/dispatch-utils.test.ts +245 -0
  175. package/server/utils/dispatch-utils.ts +97 -27
  176. package/server/utils/logger.ts +40 -3
  177. package/server/utils/package-info.ts +32 -0
  178. package/server/utils/route-helpers.ts +12 -0
  179. package/server/utils/terminal-launcher.ts +85 -25
  180. package/server/utils/worktree-manager.ts +9 -4
  181. package/server/validator.ts +270 -0
  182. package/server/watchers/global-watcher.ts +77 -0
  183. package/server/watchers/scope-watcher.ts +21 -9
  184. package/server/wizard/config-editor.ts +248 -0
  185. package/server/wizard/detect.ts +104 -0
  186. package/server/wizard/doctor.ts +114 -0
  187. package/server/wizard/index.ts +187 -0
  188. package/server/wizard/phases/confirm.ts +45 -0
  189. package/server/wizard/phases/project-setup.ts +106 -0
  190. package/server/wizard/phases/setup-wizard.ts +78 -0
  191. package/server/wizard/phases/welcome.ts +43 -0
  192. package/server/wizard/phases/workflow-setup.ts +28 -0
  193. package/server/wizard/types.ts +56 -0
  194. package/server/wizard/ui.ts +93 -0
  195. package/shared/__fixtures__/workflow-configs.ts +80 -0
  196. package/shared/default-workflow.json +65 -0
  197. package/shared/onboarding-tour.test.ts +94 -0
  198. package/shared/project-colors.ts +24 -0
  199. package/shared/workflow-config.test.ts +111 -0
  200. package/shared/workflow-config.ts +7 -0
  201. package/shared/workflow-engine.test.ts +388 -0
  202. package/shared/workflow-normalizer.test.ts +119 -0
  203. package/shared/workflow-normalizer.ts +118 -0
  204. package/templates/hooks/end-session.sh +3 -1
  205. package/templates/hooks/orbital-emit.sh +2 -2
  206. package/templates/hooks/orbital-report-deploy.sh +4 -4
  207. package/templates/hooks/orbital-report-gates.sh +4 -4
  208. package/templates/hooks/orbital-scope-update.sh +1 -1
  209. package/templates/hooks/scope-create-cleanup.sh +2 -2
  210. package/templates/hooks/scope-create-gate.sh +0 -1
  211. package/templates/hooks/scope-helpers.sh +18 -0
  212. package/templates/hooks/scope-prepare.sh +66 -11
  213. package/templates/migrations/renames.json +1 -0
  214. package/templates/orbital.config.json +7 -2
  215. package/templates/settings-hooks.json +1 -1
  216. package/templates/skills/git-commit/SKILL.md +9 -4
  217. package/templates/skills/git-dev/SKILL.md +8 -3
  218. package/templates/skills/git-main/SKILL.md +8 -2
  219. package/templates/skills/git-production/SKILL.md +6 -2
  220. package/templates/skills/git-staging/SKILL.md +8 -3
  221. package/templates/skills/scope-create/SKILL.md +17 -3
  222. package/templates/skills/scope-fix-review/SKILL.md +6 -3
  223. package/templates/skills/scope-implement/SKILL.md +4 -1
  224. package/templates/skills/scope-post-review/SKILL.md +63 -5
  225. package/templates/skills/scope-pre-review/SKILL.md +5 -2
  226. package/templates/skills/scope-verify/SKILL.md +5 -3
  227. package/templates/skills/test-code-review/SKILL.md +41 -33
  228. package/templates/skills/test-scaffold/SKILL.md +222 -0
  229. package/dist/assets/WorkflowVisualizer-BZ21PIIF.js +0 -84
  230. package/dist/assets/charts-D__PA1zp.js +0 -72
  231. package/dist/assets/index-D1G6i0nS.css +0 -1
  232. package/dist/assets/index-DpItvKpf.js +0 -419
  233. package/dist/assets/ui-BvF022GT.js +0 -53
  234. package/index.html +0 -15
  235. package/postcss.config.js +0 -6
  236. package/src/App.tsx +0 -33
  237. package/src/components/AgentBadge.tsx +0 -40
  238. package/src/components/BatchPreflightModal.tsx +0 -115
  239. package/src/components/CardDisplayToggle.tsx +0 -74
  240. package/src/components/ColumnHeaderActions.tsx +0 -55
  241. package/src/components/ColumnMenu.tsx +0 -99
  242. package/src/components/DeployHistory.tsx +0 -141
  243. package/src/components/DispatchModal.tsx +0 -164
  244. package/src/components/DispatchPopover.tsx +0 -139
  245. package/src/components/DragOverlay.tsx +0 -25
  246. package/src/components/DriftSidebar.tsx +0 -140
  247. package/src/components/EnvironmentStrip.tsx +0 -88
  248. package/src/components/ErrorBoundary.tsx +0 -62
  249. package/src/components/FilterChip.tsx +0 -105
  250. package/src/components/GateIndicator.tsx +0 -33
  251. package/src/components/IdeaDetailModal.tsx +0 -190
  252. package/src/components/IdeaFormDialog.tsx +0 -113
  253. package/src/components/KanbanColumn.tsx +0 -201
  254. package/src/components/MarkdownRenderer.tsx +0 -114
  255. package/src/components/NeonGrid.tsx +0 -128
  256. package/src/components/PromotionQueue.tsx +0 -89
  257. package/src/components/ScopeCard.tsx +0 -234
  258. package/src/components/ScopeDetailModal.tsx +0 -255
  259. package/src/components/ScopeFilterBar.tsx +0 -152
  260. package/src/components/SearchInput.tsx +0 -102
  261. package/src/components/SessionPanel.tsx +0 -335
  262. package/src/components/SprintContainer.tsx +0 -303
  263. package/src/components/SprintDependencyDialog.tsx +0 -78
  264. package/src/components/SprintPreflightModal.tsx +0 -138
  265. package/src/components/StatusBar.tsx +0 -168
  266. package/src/components/SwimCell.tsx +0 -67
  267. package/src/components/SwimLaneRow.tsx +0 -94
  268. package/src/components/SwimlaneBoardView.tsx +0 -108
  269. package/src/components/VersionBadge.tsx +0 -139
  270. package/src/components/ViewModeSelector.tsx +0 -114
  271. package/src/components/config/AgentChip.tsx +0 -53
  272. package/src/components/config/AgentCreateDialog.tsx +0 -321
  273. package/src/components/config/AgentEditor.tsx +0 -175
  274. package/src/components/config/DirectoryTree.tsx +0 -582
  275. package/src/components/config/FileEditor.tsx +0 -550
  276. package/src/components/config/HookChip.tsx +0 -50
  277. package/src/components/config/StageCard.tsx +0 -198
  278. package/src/components/config/TransitionZone.tsx +0 -173
  279. package/src/components/config/UnifiedWorkflowPipeline.tsx +0 -216
  280. package/src/components/config/WorkflowPipeline.tsx +0 -161
  281. package/src/components/source-control/BranchList.tsx +0 -93
  282. package/src/components/source-control/BranchPanel.tsx +0 -105
  283. package/src/components/source-control/CommitLog.tsx +0 -100
  284. package/src/components/source-control/CommitRow.tsx +0 -47
  285. package/src/components/source-control/GitHubPanel.tsx +0 -110
  286. package/src/components/source-control/GitHubSetupGuide.tsx +0 -52
  287. package/src/components/source-control/GitOverviewBar.tsx +0 -101
  288. package/src/components/source-control/PullRequestList.tsx +0 -69
  289. package/src/components/source-control/WorktreeList.tsx +0 -80
  290. package/src/components/ui/badge.tsx +0 -41
  291. package/src/components/ui/button.tsx +0 -55
  292. package/src/components/ui/card.tsx +0 -78
  293. package/src/components/ui/dialog.tsx +0 -94
  294. package/src/components/ui/popover.tsx +0 -33
  295. package/src/components/ui/scroll-area.tsx +0 -54
  296. package/src/components/ui/separator.tsx +0 -28
  297. package/src/components/ui/tabs.tsx +0 -52
  298. package/src/components/ui/toggle-switch.tsx +0 -35
  299. package/src/components/ui/tooltip.tsx +0 -27
  300. package/src/components/workflow/AddEdgeDialog.tsx +0 -217
  301. package/src/components/workflow/AddListDialog.tsx +0 -201
  302. package/src/components/workflow/ChecklistEditor.tsx +0 -239
  303. package/src/components/workflow/CommandPrefixManager.tsx +0 -118
  304. package/src/components/workflow/ConfigSettingsPanel.tsx +0 -189
  305. package/src/components/workflow/DirectionSelector.tsx +0 -133
  306. package/src/components/workflow/DispatchConfigPanel.tsx +0 -180
  307. package/src/components/workflow/EdgeDetailPanel.tsx +0 -236
  308. package/src/components/workflow/EdgePropertyEditor.tsx +0 -251
  309. package/src/components/workflow/EditToolbar.tsx +0 -138
  310. package/src/components/workflow/HookDetailPanel.tsx +0 -250
  311. package/src/components/workflow/HookExecutionLog.tsx +0 -24
  312. package/src/components/workflow/HookSourceModal.tsx +0 -129
  313. package/src/components/workflow/HooksDashboard.tsx +0 -363
  314. package/src/components/workflow/ListPropertyEditor.tsx +0 -251
  315. package/src/components/workflow/MigrationPreviewDialog.tsx +0 -237
  316. package/src/components/workflow/MovementRulesPanel.tsx +0 -188
  317. package/src/components/workflow/NodeDetailPanel.tsx +0 -245
  318. package/src/components/workflow/PresetSelector.tsx +0 -414
  319. package/src/components/workflow/SkillCommandBuilder.tsx +0 -174
  320. package/src/components/workflow/WorkflowEdgeComponent.tsx +0 -145
  321. package/src/components/workflow/WorkflowNode.tsx +0 -147
  322. package/src/components/workflow/graphLayout.ts +0 -186
  323. package/src/components/workflow/mergeHooks.ts +0 -85
  324. package/src/components/workflow/useEditHistory.ts +0 -88
  325. package/src/components/workflow/useWorkflowEditor.ts +0 -262
  326. package/src/components/workflow/validateConfig.ts +0 -70
  327. package/src/hooks/useActiveDispatches.ts +0 -198
  328. package/src/hooks/useBoardSettings.ts +0 -170
  329. package/src/hooks/useCardDisplay.ts +0 -57
  330. package/src/hooks/useCcHooks.ts +0 -24
  331. package/src/hooks/useConfigTree.ts +0 -51
  332. package/src/hooks/useEnforcementRules.ts +0 -46
  333. package/src/hooks/useEvents.ts +0 -59
  334. package/src/hooks/useFileEditor.ts +0 -165
  335. package/src/hooks/useGates.ts +0 -57
  336. package/src/hooks/useIdeaActions.ts +0 -53
  337. package/src/hooks/useKanbanDnd.ts +0 -410
  338. package/src/hooks/useOrbitalConfig.ts +0 -54
  339. package/src/hooks/usePipeline.ts +0 -47
  340. package/src/hooks/usePipelineData.ts +0 -338
  341. package/src/hooks/useReconnect.ts +0 -25
  342. package/src/hooks/useScopeFilters.ts +0 -125
  343. package/src/hooks/useScopeSessions.ts +0 -44
  344. package/src/hooks/useScopes.ts +0 -67
  345. package/src/hooks/useSearch.ts +0 -67
  346. package/src/hooks/useSettings.tsx +0 -187
  347. package/src/hooks/useSocket.ts +0 -25
  348. package/src/hooks/useSourceControl.ts +0 -105
  349. package/src/hooks/useSprintPreflight.ts +0 -55
  350. package/src/hooks/useSprints.ts +0 -154
  351. package/src/hooks/useStatusBarHighlight.ts +0 -18
  352. package/src/hooks/useSwimlaneBoardSettings.ts +0 -104
  353. package/src/hooks/useTheme.ts +0 -9
  354. package/src/hooks/useTransitionReadiness.ts +0 -53
  355. package/src/hooks/useVersion.ts +0 -155
  356. package/src/hooks/useViolations.ts +0 -65
  357. package/src/hooks/useWorkflow.tsx +0 -125
  358. package/src/hooks/useZoomModifier.ts +0 -19
  359. package/src/index.css +0 -797
  360. package/src/layouts/DashboardLayout.tsx +0 -113
  361. package/src/lib/collisionDetection.ts +0 -20
  362. package/src/lib/scope-fields.ts +0 -61
  363. package/src/lib/swimlane.ts +0 -146
  364. package/src/lib/utils.ts +0 -15
  365. package/src/main.tsx +0 -19
  366. package/src/socket.ts +0 -11
  367. package/src/types/index.ts +0 -497
  368. package/src/views/AgentFeed.tsx +0 -339
  369. package/src/views/DeployPipeline.tsx +0 -59
  370. package/src/views/EnforcementView.tsx +0 -378
  371. package/src/views/PrimitivesConfig.tsx +0 -500
  372. package/src/views/QualityGates.tsx +0 -1012
  373. package/src/views/ScopeBoard.tsx +0 -454
  374. package/src/views/SessionTimeline.tsx +0 -516
  375. package/src/views/Settings.tsx +0 -183
  376. package/src/views/SourceControl.tsx +0 -95
  377. package/src/views/WorkflowVisualizer.tsx +0 -382
  378. package/tailwind.config.js +0 -161
  379. package/tsconfig.json +0 -25
  380. package/vite.config.ts +0 -38
package/server/index.ts CHANGED
@@ -4,91 +4,69 @@ import { Server } from 'socket.io';
4
4
  import path from 'path';
5
5
  import fs from 'fs';
6
6
  import { fileURLToPath } from 'url';
7
- import { getDatabase, closeDatabase } from './database.js';
8
- import { getConfig, resetConfig } from './config.js';
9
- import { ScopeCache } from './services/scope-cache.js';
10
- import { ScopeService } from './services/scope-service.js';
11
- import { EventService } from './services/event-service.js';
12
- import { GateService } from './services/gate-service.js';
13
- import { DeployService } from './services/deploy-service.js';
14
- import { SprintService } from './services/sprint-service.js';
15
- import { SprintOrchestrator } from './services/sprint-orchestrator.js';
16
- import { BatchOrchestrator } from './services/batch-orchestrator.js';
17
- import { ReadinessService } from './services/readiness-service.js';
18
- import { startScopeWatcher } from './watchers/scope-watcher.js';
19
- import { startEventWatcher } from './watchers/event-watcher.js';
20
- import { ensureDynamicProfiles } from './utils/terminal-launcher.js';
21
- import { syncClaudeSessionsToDB } from './services/claude-session-service.js';
22
- import { resolveStaleDispatches, resolveActiveDispatchesForScope, resolveDispatchesByPid, resolveDispatchesByDispatchId, linkPidToDispatch } from './utils/dispatch-utils.js';
23
- import { createScopeRoutes } from './routes/scope-routes.js';
24
- import { createDataRoutes } from './routes/data-routes.js';
25
- import { createDispatchRoutes } from './routes/dispatch-routes.js';
26
- import { createSprintRoutes } from './routes/sprint-routes.js';
27
- import { createWorkflowRoutes } from './routes/workflow-routes.js';
28
- import { createConfigRoutes } from './routes/config-routes.js';
29
- import { createGitRoutes } from './routes/git-routes.js';
7
+ import type { GateRow } from './services/gate-service.js';
8
+ import { launchInTerminal } from './utils/terminal-launcher.js';
9
+ import { getClaudeSessions, getSessionStats } from './services/claude-session-service.js';
10
+ import { getActiveScopeIds, getAbandonedScopeIds } from './utils/dispatch-utils.js';
11
+ import { ConfigService, isValidPrimitiveType } from './services/config-service.js';
12
+ import { GLOBAL_PRIMITIVES_DIR } from './global-config.js';
30
13
  import { createVersionRoutes } from './routes/version-routes.js';
31
- import { WorkflowService } from './services/workflow-service.js';
32
- import { GitService } from './services/git-service.js';
33
- import { GitHubService } from './services/github-service.js';
34
14
  import { WorkflowEngine } from '../shared/workflow-engine.js';
35
- import defaultWorkflow from '../shared/default-workflow.json' with { type: 'json' };
36
- import type { WorkflowConfig } from '../shared/workflow-config.js';
15
+ import { getHookEnforcement } from '../shared/workflow-config.js';
37
16
  import { createLogger, setLogLevel } from './utils/logger.js';
38
17
  import type { LogLevel } from './utils/logger.js';
39
18
 
40
19
  import type http from 'http';
41
- import type Database from 'better-sqlite3';
42
20
 
43
- // ─── Types ──────────────────────────────────────────────────
44
-
45
- export interface ServerOverrides {
21
+ // ─── Central Server ─────────────────────────────────────────
22
+
23
+ import { ProjectManager } from './project-manager.js';
24
+ import { SyncService } from './services/sync-service.js';
25
+ import { startGlobalWatcher } from './watchers/global-watcher.js';
26
+ import { createSyncRoutes } from './routes/sync-routes.js';
27
+ import { seedGlobalPrimitives, runUpdate } from './init.js';
28
+ import { loadManifest, refreshFileStatuses, summarizeManifest } from './manifest.js';
29
+ import { getPackageVersion } from './utils/package-info.js';
30
+ import {
31
+ ensureOrbitalHome,
32
+ loadGlobalConfig,
33
+ registerProject as registerProjectGlobal,
34
+ ORBITAL_HOME,
35
+ } from './global-config.js';
36
+
37
+ export interface CentralServerOverrides {
46
38
  port?: number;
47
- projectRoot?: string;
39
+ clientPort?: number;
40
+ /** If set, auto-register this project on first launch */
41
+ autoRegisterPath?: string;
48
42
  }
49
43
 
50
- export interface ServerInstance {
44
+ export interface CentralServerInstance {
51
45
  app: express.Application;
52
46
  io: Server;
53
- db: Database.Database;
54
- workflowEngine: WorkflowEngine;
47
+ projectManager: ProjectManager;
48
+ syncService: SyncService;
55
49
  httpServer: http.Server;
56
50
  shutdown: () => Promise<void>;
57
51
  }
58
52
 
59
- // ─── Server Factory ─────────────────────────────────────────
60
-
61
- export async function startServer(overrides?: ServerOverrides): Promise<ServerInstance> {
62
- // Apply project root override before config loads
63
- if (overrides?.projectRoot) {
64
- process.env.ORBITAL_PROJECT_ROOT = overrides.projectRoot;
65
- resetConfig();
66
- }
53
+ export async function startCentralServer(overrides?: CentralServerOverrides): Promise<CentralServerInstance> {
54
+ ensureOrbitalHome();
67
55
 
68
- const config = getConfig();
69
56
  const envLevel = process.env.ORBITAL_LOG_LEVEL;
70
57
  if (envLevel && ['debug', 'info', 'warn', 'error'].includes(envLevel)) {
71
58
  setLogLevel(envLevel as LogLevel);
72
- } else {
73
- setLogLevel(config.logLevel);
74
59
  }
75
- const log = createLogger('server');
76
- const port = overrides?.port ?? config.serverPort;
77
-
78
- const workflowEngine = new WorkflowEngine(defaultWorkflow as WorkflowConfig);
79
-
80
- // Generate shell manifest for bash hooks (config-driven lifecycle)
81
- const MANIFEST_PATH = path.join(config.configDir, 'workflow-manifest.sh');
82
- if (!fs.existsSync(config.configDir)) fs.mkdirSync(config.configDir, { recursive: true });
83
- fs.writeFileSync(MANIFEST_PATH, workflowEngine.generateShellManifest(), 'utf-8');
84
-
85
- const ICEBOX_DIR = path.join(config.scopesDir, 'icebox');
86
- // Resolve path to the bundled default workflow config.
87
- const __selfDir2 = path.dirname(fileURLToPath(import.meta.url));
88
- const DEFAULT_CONFIG_PATH = path.resolve(__selfDir2, '../shared/default-workflow.json');
89
-
90
- // Ensure icebox directory exists for idea files
91
- if (!fs.existsSync(ICEBOX_DIR)) fs.mkdirSync(ICEBOX_DIR, { recursive: true });
60
+ const log = createLogger('central');
61
+ const port = overrides?.port ?? (Number(process.env.ORBITAL_SERVER_PORT) || 4444);
62
+ const clientPort = overrides?.clientPort ?? (Number(process.env.ORBITAL_CLIENT_PORT) || 4445);
63
+
64
+ // Auto-register current project if registry is empty
65
+ const globalConfig = loadGlobalConfig();
66
+ if (globalConfig.projects.length === 0 && overrides?.autoRegisterPath) {
67
+ registerProjectGlobal(overrides.autoRegisterPath);
68
+ log.info('Auto-registered current project', { path: overrides.autoRegisterPath });
69
+ }
92
70
 
93
71
  const app = express();
94
72
  const httpServer = createServer(app);
@@ -96,7 +74,6 @@ export async function startServer(overrides?: ServerOverrides): Promise<ServerIn
96
74
  const io = new Server(httpServer, {
97
75
  cors: {
98
76
  origin: (origin, callback) => {
99
- // Allow all localhost origins (dev tool, not production)
100
77
  if (!origin || origin.startsWith('http://localhost:')) {
101
78
  callback(null, true);
102
79
  } else {
@@ -107,175 +84,965 @@ export async function startServer(overrides?: ServerOverrides): Promise<ServerIn
107
84
  },
108
85
  });
109
86
 
110
- // Middleware
111
87
  app.use(express.json());
112
88
 
113
- // Initialize database
114
- const db = getDatabase();
115
-
116
- // Initialize services
117
- const scopeCache = new ScopeCache();
118
- const scopeService = new ScopeService(scopeCache, io, config.scopesDir, workflowEngine);
119
- const eventService = new EventService(db, io);
120
- const gateService = new GateService(db, io);
121
- const deployService = new DeployService(db, io);
122
- const sprintService = new SprintService(db, io, scopeService);
123
- const sprintOrchestrator = new SprintOrchestrator(db, io, sprintService, scopeService, workflowEngine);
124
- const batchOrchestrator = new BatchOrchestrator(db, io, sprintService, scopeService, workflowEngine);
125
- const readinessService = new ReadinessService(scopeService, gateService, workflowEngine, config.projectRoot);
126
- const workflowService = new WorkflowService(config.configDir, workflowEngine, config.scopesDir, DEFAULT_CONFIG_PATH);
127
- workflowService.setSocketServer(io);
128
-
129
- // Ensure in-memory engine reflects the actual active config (may differ from bundled default
130
- // if the user applied a custom preset)
131
- workflowEngine.reload(workflowService.getActive());
132
- const gitService = new GitService(config.projectRoot, scopeCache);
133
- const githubService = new GitHubService(config.projectRoot);
134
-
135
- // Wire active-group guard into scope service (blocks manual moves for scopes in active batches/sprints)
136
- scopeService.setActiveGroupCheck((scopeId) => sprintService.getActiveGroupForScope(scopeId));
137
-
138
- // ─── Event Wiring ──────────────────────────────────────────
139
-
140
- function inferScopeStatus(
141
- eventType: string,
142
- scopeId: unknown,
143
- data: Record<string, unknown>
144
- ): void {
145
- if (scopeId == null) return;
146
- const id = Number(scopeId);
147
- if (isNaN(id) || id <= 0) return;
148
-
149
- // Don't infer status for icebox idea cards
150
- const current = scopeService.getById(id);
151
- if (current?.status === 'icebox') return;
152
-
153
- const currentStatus = current?.status ?? '';
154
- const result = workflowEngine.inferStatus(eventType, currentStatus, data);
155
- if (result === null) return;
156
-
157
- // Handle dispatch resolution (AGENT_COMPLETED with outcome)
158
- if (typeof result === 'object' && 'dispatchResolution' in result) {
159
- resolveActiveDispatchesForScope(
160
- db, io, id,
161
- result.resolution as 'completed' | 'failed',
162
- );
163
- return;
89
+ // Initialize ProjectManager and boot all registered projects
90
+ const projectManager = new ProjectManager(io);
91
+ await projectManager.initializeAll();
92
+
93
+ // Seed global primitives if empty (lazy fallback for first launch)
94
+ const globalPrimitivesEmpty = ['agents', 'skills', 'hooks'].every(t => {
95
+ const dir = path.join(GLOBAL_PRIMITIVES_DIR, t);
96
+ return !fs.existsSync(dir) || fs.readdirSync(dir).filter(f => !f.startsWith('.')).length === 0;
97
+ });
98
+ if (globalPrimitivesEmpty) {
99
+ seedGlobalPrimitives();
100
+ log.info('Seeded global primitives from package templates');
101
+ }
102
+
103
+ // Initialize SyncService and global watcher
104
+ const syncService = new SyncService();
105
+ const globalWatcher = startGlobalWatcher(syncService, io);
106
+
107
+ // ─── Routes ──────────────────────────────────────────────
108
+
109
+ // Health check
110
+ app.get('/api/orbital/health', (_req, res) => {
111
+ res.json({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString() });
112
+ });
113
+
114
+ // Project management + sync routes (top-level)
115
+ app.use('/api/orbital', createSyncRoutes({ syncService, projectManager }));
116
+ app.use('/api/orbital', createVersionRoutes({ io }));
117
+
118
+ // Per-project routes — dynamic middleware that resolves :projectId
119
+ app.use('/api/orbital/projects/:projectId', (req, res, next) => {
120
+ const projectId = req.params.projectId;
121
+ const router = projectManager.getRouter(projectId);
122
+ if (!router) {
123
+ const ctx = projectManager.getContext(projectId);
124
+ if (!ctx) return res.status(404).json({ error: `Project '${projectId}' not found` });
125
+ return res.status(503).json({ error: `Project '${projectId}' is offline` });
126
+ }
127
+ router(req, res, next);
128
+ });
129
+
130
+ // Aggregate endpoints
131
+ app.get('/api/orbital/aggregate/scopes', (_req, res) => {
132
+ const allScopes: Array<Record<string, unknown>> = [];
133
+ for (const [projectId, ctx] of projectManager.getAllContexts()) {
134
+ for (const scope of ctx.scopeService.getAll()) {
135
+ allScopes.push({ ...scope, project_id: projectId });
136
+ }
137
+ }
138
+ res.json(allScopes);
139
+ });
140
+
141
+ app.get('/api/orbital/aggregate/events', (req, res) => {
142
+ const limit = Number(req.query.limit) || 50;
143
+ const allEvents: Array<Record<string, unknown>> = [];
144
+ for (const [projectId, ctx] of projectManager.getAllContexts()) {
145
+ const events = ctx.db.prepare(
146
+ `SELECT * FROM events ORDER BY timestamp DESC LIMIT ?`
147
+ ).all(limit) as Array<Record<string, unknown>>;
148
+ for (const event of events) {
149
+ allEvents.push({ ...event, project_id: projectId });
150
+ }
164
151
  }
152
+ // Sort by timestamp descending across all projects
153
+ allEvents.sort((a, b) => String(b.timestamp).localeCompare(String(a.timestamp)));
154
+ res.json(allEvents.slice(0, limit));
155
+ });
165
156
 
166
- scopeService.updateStatus(id, result, 'event');
157
+ // Aggregate sessions across all projects
158
+ const JSON_FIELDS = ['tags', 'blocked_by', 'blocks', 'data', 'discoveries', 'next_steps', 'details'];
159
+ function parseJsonFields(row: Record<string, unknown>): Record<string, unknown> {
160
+ const parsed = { ...row };
161
+ for (const field of JSON_FIELDS) {
162
+ if (typeof parsed[field] === 'string') {
163
+ try { parsed[field] = JSON.parse(parsed[field] as string); } catch { /* keep string */ }
164
+ }
165
+ }
166
+ return parsed;
167
167
  }
168
168
 
169
- eventService.onIngest((eventType, scopeId, data) => {
170
- // Handle SESSION_START: link PID to dispatch via dispatch_id env var
171
- if (eventType === 'SESSION_START' && typeof data.dispatch_id === 'string' && typeof data.pid === 'number') {
172
- linkPidToDispatch(db, data.dispatch_id, data.pid);
173
- log.info('SESSION_START: linked PID to dispatch', { pid: data.pid, dispatch_id: data.dispatch_id });
169
+ app.get('/api/orbital/aggregate/sessions', (_req, res) => {
170
+ const allRows: Array<Record<string, unknown>> = [];
171
+ for (const [projectId, ctx] of projectManager.getAllContexts()) {
172
+ const rows = ctx.db.prepare(
173
+ 'SELECT * FROM sessions ORDER BY started_at DESC'
174
+ ).all() as Array<Record<string, unknown>>;
175
+ for (const row of rows) {
176
+ allRows.push({ ...parseJsonFields(row), project_id: projectId });
177
+ }
178
+ }
179
+
180
+ // Deduplicate by claude_session_id, aggregate scope_ids and actions
181
+ const seen = new Map<string, Record<string, unknown>>();
182
+ const scopeMap = new Map<string, number[]>();
183
+ const actionMap = new Map<string, string[]>();
184
+
185
+ for (const row of allRows) {
186
+ const key = (row.claude_session_id as string | null) ?? (row.id as string);
187
+ if (!seen.has(key)) {
188
+ seen.set(key, row);
189
+ scopeMap.set(key, []);
190
+ actionMap.set(key, []);
191
+ }
192
+ const sid = row.scope_id as number | null;
193
+ if (sid != null) {
194
+ const arr = scopeMap.get(key)!;
195
+ if (!arr.includes(sid)) arr.push(sid);
196
+ }
197
+ const action = row.action as string | null;
198
+ if (action) {
199
+ const actions = actionMap.get(key)!;
200
+ if (!actions.includes(action)) actions.push(action);
201
+ }
202
+ }
203
+
204
+ const results = [...seen.values()].map((row) => {
205
+ const key = (row.claude_session_id as string | null) ?? (row.id as string);
206
+ return { ...row, scope_ids: scopeMap.get(key) ?? [], actions: actionMap.get(key) ?? [] };
207
+ });
208
+
209
+ // Sort by started_at descending across all projects
210
+ results.sort((a, b) =>
211
+ String((b as Record<string, unknown>).started_at ?? '').localeCompare(
212
+ String((a as Record<string, unknown>).started_at ?? ''),
213
+ ),
214
+ );
215
+ res.json(results.slice(0, 50));
216
+ });
217
+
218
+ app.get('/api/orbital/aggregate/sessions/:id/content', async (req, res) => {
219
+ const sessionId = req.params.id;
220
+
221
+ // Find the session across all project databases
222
+ let session: Record<string, unknown> | undefined;
223
+ let matchedProjectRoot: string | undefined;
224
+ for (const [, ctx] of projectManager.getAllContexts()) {
225
+ const row = ctx.db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId) as Record<string, unknown> | undefined;
226
+ if (row) {
227
+ session = parseJsonFields(row);
228
+ matchedProjectRoot = ctx.config.projectRoot;
229
+ break;
230
+ }
231
+ }
232
+
233
+ if (!session || !matchedProjectRoot) {
234
+ res.status(404).json({ error: 'Session not found' });
235
+ return;
236
+ }
237
+
238
+ let content = '';
239
+ let meta: Record<string, unknown> | null = null;
240
+ let stats: Record<string, unknown> | null = null;
241
+
242
+ if (session.claude_session_id && typeof session.claude_session_id === 'string') {
243
+ const claudeSessions = await getClaudeSessions(undefined, matchedProjectRoot);
244
+ const match = claudeSessions.find(s => s.id === session!.claude_session_id);
245
+ if (match) {
246
+ meta = {
247
+ slug: match.slug,
248
+ branch: match.branch,
249
+ fileSize: match.fileSize,
250
+ summary: match.summary,
251
+ startedAt: match.startedAt,
252
+ lastActiveAt: match.lastActiveAt,
253
+ };
254
+ }
255
+ stats = getSessionStats(session.claude_session_id, matchedProjectRoot) as Record<string, unknown> | null;
256
+ }
257
+
258
+ if (!content) {
259
+ const parts: string[] = [];
260
+ if (session.summary) parts.push(`# ${session.summary}\n`);
261
+ const discoveries = Array.isArray(session.discoveries) ? session.discoveries : [];
262
+ if (discoveries.length > 0) {
263
+ parts.push('## Completed\n');
264
+ for (const d of discoveries) parts.push(`- ${d}`);
265
+ parts.push('');
266
+ }
267
+ const nextSteps = Array.isArray(session.next_steps) ? session.next_steps : [];
268
+ if (nextSteps.length > 0) {
269
+ parts.push('## Next Steps\n');
270
+ for (const n of nextSteps) parts.push(`- ${n}`);
271
+ }
272
+ content = parts.join('\n');
273
+ }
274
+
275
+ res.json({
276
+ id: session.id,
277
+ content,
278
+ claude_session_id: session.claude_session_id ?? null,
279
+ meta,
280
+ stats,
281
+ });
282
+ });
283
+
284
+ app.post('/api/orbital/aggregate/sessions/:id/resume', async (req, res) => {
285
+ const sessionId = req.params.id;
286
+ const { claude_session_id } = req.body as { claude_session_id?: string };
287
+
288
+ if (!claude_session_id || !/^[0-9a-f-]{36}$/i.test(claude_session_id)) {
289
+ res.status(400).json({ error: 'Valid claude_session_id (UUID) required' });
174
290
  return;
175
291
  }
176
292
 
177
- // Handle SESSION_END: resolve dispatches by dispatch_id (preferred) or PID (fallback)
178
- if (eventType === 'SESSION_END') {
179
- let count = 0;
180
- if (typeof data.dispatch_id === 'string') {
181
- count = resolveDispatchesByDispatchId(db, io, data.dispatch_id);
182
- if (count > 0) {
183
- log.info('SESSION_END: resolved dispatches', { count, dispatch_id: data.dispatch_id });
293
+ // Find the session's project root
294
+ let matchedProjectRoot: string | undefined;
295
+ for (const [, ctx] of projectManager.getAllContexts()) {
296
+ const row = ctx.db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId);
297
+ if (row) {
298
+ matchedProjectRoot = ctx.config.projectRoot;
299
+ break;
300
+ }
301
+ }
302
+
303
+ if (!matchedProjectRoot) {
304
+ res.status(404).json({ error: 'Session not found' });
305
+ return;
306
+ }
307
+
308
+ const resumeCmd = `cd '${matchedProjectRoot}' && claude --dangerously-skip-permissions --resume '${claude_session_id}'`;
309
+ try {
310
+ await launchInTerminal(resumeCmd);
311
+ res.json({ ok: true, session_id: claude_session_id });
312
+ } catch (err) {
313
+ log.error('Terminal launch failed', { error: String(err) });
314
+ res.status(500).json({ error: 'Failed to launch terminal', details: String(err) });
315
+ }
316
+ });
317
+
318
+ // ─── Aggregate: Enforcement & Gates ──────────────────────
319
+
320
+ app.get('/api/orbital/aggregate/events/violations/summary', (_req, res) => {
321
+ try {
322
+ const mergedByRule = new Map<string, { rule: string; count: number; last_seen: string }>();
323
+ const mergedByFile = new Map<string, { file: string; count: number }>();
324
+ let allOverrides: Array<{ rule: string; reason: string; date: string }> = [];
325
+ let totalViolations = 0;
326
+ let totalOverrides = 0;
327
+
328
+ for (const [, ctx] of projectManager.getAllContexts()) {
329
+ const byRule = ctx.db.prepare(
330
+ `SELECT JSON_EXTRACT(data, '$.rule') as rule, COUNT(*) as count, MAX(timestamp) as last_seen
331
+ FROM events WHERE type = 'VIOLATION' GROUP BY rule ORDER BY count DESC`
332
+ ).all() as Array<{ rule: string; count: number; last_seen: string }>;
333
+ for (const r of byRule) {
334
+ const existing = mergedByRule.get(r.rule);
335
+ if (existing) {
336
+ existing.count += r.count;
337
+ if (r.last_seen > existing.last_seen) existing.last_seen = r.last_seen;
338
+ } else {
339
+ mergedByRule.set(r.rule, { ...r });
340
+ }
341
+ }
342
+
343
+ const byFile = ctx.db.prepare(
344
+ `SELECT JSON_EXTRACT(data, '$.file') as file, COUNT(*) as count FROM events
345
+ WHERE type = 'VIOLATION' AND JSON_EXTRACT(data, '$.file') IS NOT NULL AND JSON_EXTRACT(data, '$.file') != ''
346
+ GROUP BY file ORDER BY count DESC LIMIT 20`
347
+ ).all() as Array<{ file: string; count: number }>;
348
+ for (const f of byFile) {
349
+ const existing = mergedByFile.get(f.file);
350
+ if (existing) {
351
+ existing.count += f.count;
352
+ } else {
353
+ mergedByFile.set(f.file, { ...f });
354
+ }
355
+ }
356
+
357
+ const overrides = ctx.db.prepare(
358
+ `SELECT JSON_EXTRACT(data, '$.rule') as rule, JSON_EXTRACT(data, '$.reason') as reason, timestamp as date
359
+ FROM events WHERE type = 'OVERRIDE' ORDER BY timestamp DESC LIMIT 50`
360
+ ).all() as Array<{ rule: string; reason: string; date: string }>;
361
+ allOverrides = allOverrides.concat(overrides);
362
+
363
+ const tv = ctx.db.prepare(`SELECT COUNT(*) as count FROM events WHERE type = 'VIOLATION'`).get() as { count: number };
364
+ const to = ctx.db.prepare(`SELECT COUNT(*) as count FROM events WHERE type = 'OVERRIDE'`).get() as { count: number };
365
+ totalViolations += tv.count;
366
+ totalOverrides += to.count;
367
+ }
368
+
369
+ const byRule = [...mergedByRule.values()].sort((a, b) => b.count - a.count);
370
+ const byFile = [...mergedByFile.values()].sort((a, b) => b.count - a.count).slice(0, 20);
371
+ allOverrides.sort((a, b) => b.date.localeCompare(a.date));
372
+
373
+ res.json({ byRule, byFile, overrides: allOverrides.slice(0, 50), totalViolations, totalOverrides });
374
+ } catch (err) {
375
+ log.error('Violations summary failed', { error: String(err) });
376
+ res.status(500).json({ error: 'Failed to aggregate violations summary' });
377
+ }
378
+ });
379
+
380
+ app.get('/api/orbital/aggregate/enforcement/rules', (_req, res) => {
381
+ try {
382
+ const hookMap = new Map<string, {
383
+ hook: ReturnType<WorkflowEngine['getAllHooks']>[number];
384
+ enforcement: string;
385
+ edges: Array<{ from: string; to: string; label: string }>;
386
+ stats: { violations: number; overrides: number; last_triggered: string | null };
387
+ }>();
388
+ const summary = { guards: 0, gates: 0, lifecycle: 0, observers: 0 };
389
+ const edgeIdSet = new Set<string>();
390
+ let totalEdges = 0;
391
+
392
+ for (const [, ctx] of projectManager.getAllContexts()) {
393
+ const allHooks = ctx.workflowEngine.getAllHooks();
394
+ const allEdges = ctx.workflowEngine.getAllEdges();
395
+
396
+ // Build edge map for this project
397
+ const hookEdgeMap = new Map<string, Array<{ from: string; to: string; label: string }>>();
398
+ for (const edge of allEdges) {
399
+ const edgeKey = `${edge.from}->${edge.to}`;
400
+ if (!edgeIdSet.has(edgeKey)) {
401
+ edgeIdSet.add(edgeKey);
402
+ totalEdges++;
403
+ }
404
+ for (const hookId of edge.hooks ?? []) {
405
+ if (!hookEdgeMap.has(hookId)) hookEdgeMap.set(hookId, []);
406
+ hookEdgeMap.get(hookId)!.push({ from: edge.from, to: edge.to, label: edge.label });
407
+ }
408
+ }
409
+
410
+ // Query stats from this project's DB
411
+ const violationStats = ctx.db.prepare(
412
+ `SELECT JSON_EXTRACT(data, '$.rule') as rule, COUNT(*) as count, MAX(timestamp) as last_seen
413
+ FROM events WHERE type = 'VIOLATION' GROUP BY rule`
414
+ ).all() as Array<{ rule: string; count: number; last_seen: string }>;
415
+ const overrideStats = ctx.db.prepare(
416
+ `SELECT JSON_EXTRACT(data, '$.rule') as rule, COUNT(*) as count
417
+ FROM events WHERE type = 'OVERRIDE' GROUP BY rule`
418
+ ).all() as Array<{ rule: string; count: number }>;
419
+ const violationMap = new Map(violationStats.map((v) => [v.rule, v]));
420
+ const overrideMap = new Map(overrideStats.map((o) => [o.rule, o]));
421
+
422
+ for (const hook of allHooks) {
423
+ const existing = hookMap.get(hook.id);
424
+ const projViolations = violationMap.get(hook.id)?.count ?? 0;
425
+ const projOverrides = overrideMap.get(hook.id)?.count ?? 0;
426
+ const projLastTriggered = violationMap.get(hook.id)?.last_seen ?? null;
427
+
428
+ if (existing) {
429
+ // Sum stats across projects
430
+ existing.stats.violations += projViolations;
431
+ existing.stats.overrides += projOverrides;
432
+ if (projLastTriggered && (!existing.stats.last_triggered || projLastTriggered > existing.stats.last_triggered)) {
433
+ existing.stats.last_triggered = projLastTriggered;
434
+ }
435
+ // Union edges
436
+ const existingEdgeKeys = new Set(existing.edges.map((e) => `${e.from}->${e.to}`));
437
+ for (const edge of hookEdgeMap.get(hook.id) ?? []) {
438
+ if (!existingEdgeKeys.has(`${edge.from}->${edge.to}`)) {
439
+ existing.edges.push(edge);
440
+ }
441
+ }
442
+ } else {
443
+ // First time seeing this hook — count it in summary
444
+ if (hook.category === 'guard') summary.guards++;
445
+ else if (hook.category === 'gate') summary.gates++;
446
+ else if (hook.category === 'lifecycle') summary.lifecycle++;
447
+ else if (hook.category === 'observer') summary.observers++;
448
+
449
+ hookMap.set(hook.id, {
450
+ hook,
451
+ enforcement: getHookEnforcement(hook),
452
+ edges: hookEdgeMap.get(hook.id) ?? [],
453
+ stats: {
454
+ violations: projViolations,
455
+ overrides: projOverrides,
456
+ last_triggered: projLastTriggered,
457
+ },
458
+ });
459
+ }
460
+ }
461
+ }
462
+
463
+ res.json({ summary, rules: [...hookMap.values()], totalEdges });
464
+ } catch (err) {
465
+ log.error('Enforcement rules failed', { error: String(err) });
466
+ res.status(500).json({ error: 'Failed to aggregate enforcement rules' });
467
+ }
468
+ });
469
+
470
+ app.get('/api/orbital/aggregate/events/violations/trend', (req, res) => {
471
+ try {
472
+ const days = Number(req.query.days) || 30;
473
+ const merged = new Map<string, { day: string; rule: string; count: number }>();
474
+
475
+ for (const [, ctx] of projectManager.getAllContexts()) {
476
+ const trend = ctx.db.prepare(
477
+ `SELECT date(timestamp) as day, JSON_EXTRACT(data, '$.rule') as rule, COUNT(*) as count
478
+ FROM events WHERE type = 'VIOLATION' AND timestamp >= datetime('now', ? || ' days')
479
+ GROUP BY day, rule ORDER BY day ASC`
480
+ ).all(`-${days}`) as Array<{ day: string; rule: string; count: number }>;
481
+ for (const t of trend) {
482
+ const key = `${t.day}:${t.rule}`;
483
+ const existing = merged.get(key);
484
+ if (existing) {
485
+ existing.count += t.count;
486
+ } else {
487
+ merged.set(key, { ...t });
488
+ }
184
489
  }
185
490
  }
186
- // PID fallback for old hooks without dispatch_id
187
- if (count === 0 && typeof data.pid === 'number') {
188
- count = resolveDispatchesByPid(db, io, data.pid);
189
- if (count > 0) {
190
- log.info('SESSION_END: resolved dispatches by PID fallback', { count, pid: data.pid });
491
+
492
+ const result = [...merged.values()].sort((a, b) => a.day.localeCompare(b.day));
493
+ res.json(result);
494
+ } catch (err) {
495
+ log.error('Violation trends failed', { error: String(err) });
496
+ res.status(500).json({ error: 'Failed to aggregate violation trends' });
497
+ }
498
+ });
499
+
500
+ app.get('/api/orbital/aggregate/gates', (req, res) => {
501
+ try {
502
+ const scopeId = req.query.scope_id;
503
+ const filterProjectId = req.query.project_id as string | undefined;
504
+ const mergedGates = new Map<string, GateRow & { project_id: string }>();
505
+
506
+ for (const [projectId, ctx] of projectManager.getAllContexts()) {
507
+ if (filterProjectId && projectId !== filterProjectId) continue;
508
+ const gates = scopeId
509
+ ? ctx.gateService.getLatestForScope(Number(scopeId))
510
+ : ctx.gateService.getLatestRun();
511
+ for (const gate of gates) {
512
+ const existing = mergedGates.get(gate.gate_name);
513
+ if (!existing || gate.run_at > existing.run_at) {
514
+ mergedGates.set(gate.gate_name, { ...gate, project_id: projectId });
515
+ }
191
516
  }
192
517
  }
193
- // Immediately resolve any batches/sprints whose session just ended,
194
- // rather than waiting for the next stale-check interval
195
- if (count > 0) {
196
- batchOrchestrator.resolveStaleBatches();
518
+
519
+ res.json([...mergedGates.values()]);
520
+ } catch (err) {
521
+ log.error('Gates aggregation failed', { error: String(err) });
522
+ res.status(500).json({ error: 'Failed to aggregate gates' });
523
+ }
524
+ });
525
+
526
+ app.get('/api/orbital/aggregate/gates/stats', (_req, res) => {
527
+ try {
528
+ const merged = new Map<string, { gate_name: string; total: number; passed: number; failed: number }>();
529
+
530
+ for (const [, ctx] of projectManager.getAllContexts()) {
531
+ const stats = ctx.gateService.getStats();
532
+ for (const s of stats) {
533
+ const existing = merged.get(s.gate_name);
534
+ if (existing) {
535
+ existing.total += s.total;
536
+ existing.passed += s.passed;
537
+ existing.failed += s.failed;
538
+ } else {
539
+ merged.set(s.gate_name, { ...s });
540
+ }
541
+ }
197
542
  }
543
+
544
+ res.json([...merged.values()]);
545
+ } catch (err) {
546
+ log.error('Gate stats failed', { error: String(err) });
547
+ res.status(500).json({ error: 'Failed to aggregate gate stats' });
548
+ }
549
+ });
550
+
551
+ // ─── Aggregate: Git & GitHub ───────────────────────────────
552
+
553
+ app.get('/api/orbital/aggregate/git/overview', async (_req, res) => {
554
+ try {
555
+ const projects = projectManager.getProjectList();
556
+ const results = await Promise.allSettled(
557
+ projects.filter(p => p.enabled && p.status === 'active').map(async (proj) => {
558
+ const ctx = projectManager.getContext(proj.id);
559
+ if (!ctx) throw new Error('Project offline');
560
+ const config = ctx.workflowEngine.getConfig();
561
+ const overview = await ctx.gitService.getOverview(config.branchingMode ?? 'trunk');
562
+ return {
563
+ projectId: proj.id,
564
+ projectName: proj.name,
565
+ projectColor: proj.color,
566
+ status: 'ok' as const,
567
+ overview,
568
+ };
569
+ }),
570
+ );
571
+
572
+ const overviews = results.map((r, i) => {
573
+ if (r.status === 'fulfilled') return r.value;
574
+ const proj = projects.filter(p => p.enabled && p.status === 'active')[i];
575
+ return {
576
+ projectId: proj.id,
577
+ projectName: proj.name,
578
+ projectColor: proj.color,
579
+ status: 'error' as const,
580
+ error: String((r as PromiseRejectedResult).reason),
581
+ };
582
+ });
583
+
584
+ res.json(overviews);
585
+ } catch (err) {
586
+ log.error('Git overviews failed', { error: String(err) });
587
+ res.status(500).json({ error: 'Failed to aggregate git overviews' });
588
+ }
589
+ });
590
+
591
+ app.get('/api/orbital/aggregate/git/commits', async (req, res) => {
592
+ try {
593
+ const limit = Number(req.query.limit) || 50;
594
+ const projects = projectManager.getProjectList().filter(p => p.enabled && p.status === 'active');
595
+
596
+ const results = await Promise.allSettled(
597
+ projects.map(async (proj) => {
598
+ const ctx = projectManager.getContext(proj.id);
599
+ if (!ctx) return [];
600
+ const commits = await ctx.gitService.getCommits({ limit });
601
+ return commits.map(c => ({
602
+ ...c,
603
+ project_id: proj.id,
604
+ projectName: proj.name,
605
+ projectColor: proj.color,
606
+ }));
607
+ }),
608
+ );
609
+
610
+ const allCommits: Array<Record<string, unknown>> = [];
611
+ for (const r of results) {
612
+ if (r.status === 'fulfilled') allCommits.push(...r.value);
613
+ }
614
+ allCommits.sort((a, b) => String(b.date).localeCompare(String(a.date)));
615
+ res.json(allCommits.slice(0, limit));
616
+ } catch (err) {
617
+ log.error('Commits aggregation failed', { error: String(err) });
618
+ res.status(500).json({ error: 'Failed to aggregate commits' });
619
+ }
620
+ });
621
+
622
+ app.get('/api/orbital/aggregate/github/prs', async (_req, res) => {
623
+ try {
624
+ const projects = projectManager.getProjectList().filter(p => p.enabled && p.status === 'active');
625
+
626
+ const results = await Promise.allSettled(
627
+ projects.map(async (proj) => {
628
+ const ctx = projectManager.getContext(proj.id);
629
+ if (!ctx) return [];
630
+ const prs = await ctx.githubService.getOpenPRs();
631
+ return prs.map(pr => ({
632
+ ...pr,
633
+ project_id: proj.id,
634
+ projectName: proj.name,
635
+ projectColor: proj.color,
636
+ }));
637
+ }),
638
+ );
639
+
640
+ const allPrs: Array<Record<string, unknown>> = [];
641
+ for (const r of results) {
642
+ if (r.status === 'fulfilled') allPrs.push(...r.value);
643
+ }
644
+ allPrs.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
645
+ res.json(allPrs);
646
+ } catch (err) {
647
+ log.error('PRs aggregation failed', { error: String(err) });
648
+ res.status(500).json({ error: 'Failed to aggregate PRs' });
649
+ }
650
+ });
651
+
652
+ app.get('/api/orbital/aggregate/git/health', async (_req, res) => {
653
+ try {
654
+ const projects = projectManager.getProjectList().filter(p => p.enabled && p.status === 'active');
655
+
656
+ const results = await Promise.allSettled(
657
+ projects.map(async (proj) => {
658
+ const ctx = projectManager.getContext(proj.id);
659
+ if (!ctx) throw new Error('offline');
660
+ const branches = await ctx.gitService.getBranches();
661
+ const config = ctx.workflowEngine.getConfig();
662
+ const listsWithBranch = config.lists.filter(l => l.gitBranch).sort((a, b) => a.order - b.order);
663
+ const driftPairs: Array<{ from: string; to: string }> = [];
664
+ for (let i = 0; i < listsWithBranch.length - 1; i++) {
665
+ driftPairs.push({ from: listsWithBranch[i].gitBranch!, to: listsWithBranch[i + 1].gitBranch! });
666
+ }
667
+ const drift = driftPairs.length > 0 ? await ctx.gitService.getDrift(driftPairs) : [];
668
+ const maxDrift = Math.max(0, ...drift.map(d => d.count));
669
+ const staleBranches = branches.filter(b => b.isStale && !b.isRemote);
670
+
671
+ return {
672
+ projectId: proj.id,
673
+ projectName: proj.name,
674
+ projectColor: proj.color,
675
+ branchCount: branches.filter(b => !b.isRemote).length,
676
+ staleBranchCount: staleBranches.length,
677
+ featureBranchCount: branches.filter(b => !b.isRemote && /(?:feat|fix|scope)[/-]/.test(b.name)).length,
678
+ maxDriftSeverity: maxDrift === 0 ? 'clean' : maxDrift <= 5 ? 'low' : maxDrift <= 20 ? 'moderate' : 'high',
679
+ };
680
+ }),
681
+ );
682
+
683
+ const health: Array<Record<string, unknown>> = [];
684
+ for (const r of results) {
685
+ if (r.status === 'fulfilled') health.push(r.value);
686
+ }
687
+ res.json(health);
688
+ } catch (err) {
689
+ log.error('Branch health failed', { error: String(err) });
690
+ res.status(500).json({ error: 'Failed to aggregate branch health' });
691
+ }
692
+ });
693
+
694
+ app.get('/api/orbital/aggregate/git/activity', async (req, res) => {
695
+ try {
696
+ const days = Number(req.query.days) || 30;
697
+ const projects = projectManager.getProjectList().filter(p => p.enabled && p.status === 'active');
698
+
699
+ const results = await Promise.allSettled(
700
+ projects.map(async (proj) => {
701
+ const ctx = projectManager.getContext(proj.id);
702
+ if (!ctx) return { projectId: proj.id, series: [] };
703
+ const series = await ctx.gitService.getActivitySeries(days);
704
+ return { projectId: proj.id, projectName: proj.name, projectColor: proj.color, series };
705
+ }),
706
+ );
707
+
708
+ const activity: Array<Record<string, unknown>> = [];
709
+ for (const r of results) {
710
+ if (r.status === 'fulfilled') activity.push(r.value);
711
+ }
712
+ res.json(activity);
713
+ } catch (err) {
714
+ log.error('Activity aggregation failed', { error: String(err) });
715
+ res.status(500).json({ error: 'Failed to aggregate activity' });
716
+ }
717
+ });
718
+
719
+ app.get('/api/orbital/aggregate/scopes/:id/readiness', (req, res) => {
720
+ const scopeId = Number(req.params.id);
721
+ const projectId = req.query.project_id as string | undefined;
722
+
723
+ for (const [pid, ctx] of projectManager.getAllContexts()) {
724
+ if (projectId && pid !== projectId) continue;
725
+ const scope = ctx.scopeService.getById(scopeId);
726
+ if (scope) {
727
+ const readiness = ctx.readinessService.getReadiness(scopeId);
728
+ if (readiness) {
729
+ res.json(readiness);
730
+ return;
731
+ }
732
+ }
733
+ }
734
+ res.status(404).json({ error: 'Scope not found in any project' });
735
+ });
736
+
737
+ app.get('/api/orbital/aggregate/dispatch/active-scopes', (_req, res) => {
738
+ const allActive: Array<{ scope_id: number; project_id: string }> = [];
739
+ const seenActive = new Set<string>();
740
+ const allAbandoned: Array<{ scope_id: number; project_id: string; from_status: string | null; abandoned_at: string }> = [];
741
+ const seenAbandoned = new Set<string>();
742
+
743
+ for (const [projectId, ctx] of projectManager.getAllContexts()) {
744
+ const activeIds = getActiveScopeIds(ctx.db, ctx.scopeService, ctx.workflowEngine);
745
+ for (const id of activeIds) {
746
+ const key = `${projectId}::${id}`;
747
+ if (!seenActive.has(key)) {
748
+ seenActive.add(key);
749
+ allActive.push({ scope_id: id, project_id: projectId });
750
+ }
751
+ }
752
+
753
+ const abandoned = getAbandonedScopeIds(ctx.db, ctx.scopeService, ctx.workflowEngine, activeIds);
754
+ for (const entry of abandoned) {
755
+ const key = `${projectId}::${entry.scope_id}`;
756
+ if (!seenAbandoned.has(key)) {
757
+ seenAbandoned.add(key);
758
+ allAbandoned.push({ ...entry, project_id: projectId });
759
+ }
760
+ }
761
+ }
762
+
763
+ res.json({ scope_ids: allActive, abandoned_scopes: allAbandoned });
764
+ });
765
+
766
+ app.get('/api/orbital/aggregate/dispatch/active', (req, res) => {
767
+ const scopeId = Number(req.query.scope_id);
768
+ if (isNaN(scopeId) || scopeId <= 0) {
769
+ res.status(400).json({ error: 'Valid scope_id query param required' });
770
+ return;
771
+ }
772
+
773
+ for (const [, ctx] of projectManager.getAllContexts()) {
774
+ const scope = ctx.scopeService.getById(scopeId);
775
+ if (!scope) continue;
776
+
777
+ const active = ctx.db.prepare(
778
+ `SELECT id, timestamp, JSON_EXTRACT(data, '$.command') as command
779
+ FROM events
780
+ WHERE type = 'DISPATCH' AND scope_id = ? AND JSON_EXTRACT(data, '$.resolved') IS NULL
781
+ ORDER BY timestamp DESC LIMIT 1`
782
+ ).get(scopeId) as { id: string; timestamp: string; command: string } | undefined;
783
+
784
+ res.json({ active: active ?? null });
198
785
  return;
199
786
  }
200
787
 
201
- inferScopeStatus(eventType, scopeId, data);
788
+ res.json({ active: null });
202
789
  });
203
790
 
204
- scopeService.onStatusChange((scopeId, newStatus) => {
205
- if (newStatus === 'dev') {
206
- sprintOrchestrator.onScopeReachedDev(scopeId);
791
+ // ─── Aggregate: Manifest Health ────────────────────────────
792
+
793
+ app.get('/api/orbital/aggregate/manifest/status', (_req, res) => {
794
+ try {
795
+ const projects = projectManager.getProjectList().filter(p => p.enabled);
796
+ const pkgVersion = getPackageVersion();
797
+
798
+ const projectOverviews = projects.map((proj) => {
799
+ const ctx = projectManager.getContext(proj.id);
800
+ if (!ctx) {
801
+ return {
802
+ projectId: proj.id,
803
+ projectName: proj.name,
804
+ projectColor: proj.color,
805
+ status: 'error' as const,
806
+ manifest: null,
807
+ error: 'Project offline',
808
+ };
809
+ }
810
+
811
+ try {
812
+ const manifest = loadManifest(ctx.config.projectRoot);
813
+ if (!manifest) {
814
+ return {
815
+ projectId: proj.id,
816
+ projectName: proj.name,
817
+ projectColor: proj.color,
818
+ status: 'no-manifest' as const,
819
+ manifest: null,
820
+ };
821
+ }
822
+
823
+ const claudeDir = path.join(ctx.config.projectRoot, '.claude');
824
+ refreshFileStatuses(manifest, claudeDir);
825
+ const summary = summarizeManifest(manifest);
826
+
827
+ return {
828
+ projectId: proj.id,
829
+ projectName: proj.name,
830
+ projectColor: proj.color,
831
+ status: 'ok' as const,
832
+ manifest: {
833
+ exists: true,
834
+ packageVersion: pkgVersion,
835
+ installedVersion: manifest.packageVersion,
836
+ needsUpdate: manifest.packageVersion !== pkgVersion,
837
+ preset: manifest.preset,
838
+ files: summary,
839
+ lastUpdated: manifest.updatedAt,
840
+ },
841
+ };
842
+ } catch (err) {
843
+ return {
844
+ projectId: proj.id,
845
+ projectName: proj.name,
846
+ projectColor: proj.color,
847
+ status: 'error' as const,
848
+ manifest: null,
849
+ error: String(err),
850
+ };
851
+ }
852
+ });
853
+
854
+ const projectsUpToDate = projectOverviews.filter(p => p.status === 'ok' && !p.manifest?.needsUpdate).length;
855
+ const projectsOutdated = projectOverviews.filter(p => p.status === 'ok' && p.manifest?.needsUpdate).length;
856
+ const noManifest = projectOverviews.filter(p => p.status === 'no-manifest').length;
857
+ const totalOutdated = projectOverviews.reduce((sum, p) => sum + (p.manifest?.files.outdated ?? 0), 0);
858
+ const totalModified = projectOverviews.reduce((sum, p) => sum + (p.manifest?.files.modified ?? 0), 0);
859
+ const totalPinned = projectOverviews.reduce((sum, p) => sum + (p.manifest?.files.pinned ?? 0), 0);
860
+ const totalMissing = projectOverviews.reduce((sum, p) => sum + (p.manifest?.files.missing ?? 0), 0);
861
+ const totalSynced = projectOverviews.reduce((sum, p) => sum + (p.manifest?.files.synced ?? 0), 0);
862
+ const totalUserOwned = projectOverviews.reduce((sum, p) => sum + (p.manifest?.files.userOwned ?? 0), 0);
863
+
864
+ res.json({
865
+ total: projects.length,
866
+ projectsUpToDate,
867
+ projectsOutdated,
868
+ noManifest,
869
+ totalOutdated,
870
+ totalModified,
871
+ totalPinned,
872
+ totalMissing,
873
+ totalSynced,
874
+ totalUserOwned,
875
+ projects: projectOverviews,
876
+ });
877
+ } catch (err) {
878
+ log.error('Manifest status failed', { error: String(err) });
879
+ res.status(500).json({ error: 'Failed to aggregate manifest status' });
207
880
  }
208
- // Batch orchestrator tracks all status transitions (dev, staging, production)
209
- batchOrchestrator.onScopeStatusChanged(scopeId, newStatus);
210
881
  });
211
882
 
212
- scopeService.onStatusChange((scopeId, newStatus) => {
213
- if (workflowEngine.isTerminalStatus(newStatus)) {
214
- resolveActiveDispatchesForScope(db, io, scopeId, 'completed');
883
+ app.post('/api/orbital/aggregate/manifest/update-all', (_req, res) => {
884
+ try {
885
+ const projects = projectManager.getProjectList().filter(p => p.enabled);
886
+ const pkgVersion = getPackageVersion();
887
+ const results: Array<{ projectId: string; success: boolean; error?: string }> = [];
888
+
889
+ for (const proj of projects) {
890
+ const ctx = projectManager.getContext(proj.id);
891
+ if (!ctx) {
892
+ results.push({ projectId: proj.id, success: false, error: 'Project offline' });
893
+ continue;
894
+ }
895
+
896
+ const manifest = loadManifest(ctx.config.projectRoot);
897
+ if (!manifest) continue; // uninitialized — skip
898
+
899
+ // Refresh statuses and check if there's anything to update
900
+ const claudeDir = path.join(ctx.config.projectRoot, '.claude');
901
+ refreshFileStatuses(manifest, claudeDir);
902
+ const manifestSummary = summarizeManifest(manifest);
903
+ if (manifest.packageVersion === pkgVersion && manifestSummary.outdated === 0 && manifestSummary.missing === 0) {
904
+ continue; // fully up to date
905
+ }
906
+
907
+ try {
908
+ runUpdate(ctx.config.projectRoot, { dryRun: false });
909
+ ctx.emitter.emit('manifest:changed', { action: 'updated' });
910
+ results.push({ projectId: proj.id, success: true });
911
+ } catch (err) {
912
+ results.push({ projectId: proj.id, success: false, error: String(err) });
913
+ }
914
+ }
915
+
916
+ res.json({ success: true, results });
917
+ } catch (err) {
918
+ log.error('Update all projects failed', { error: String(err) });
919
+ res.status(500).json({ error: 'Failed to update all projects' });
215
920
  }
216
921
  });
217
922
 
218
- // ─── Routes ────────────────────────────────────────────────
923
+ // ─── Aggregate: Config Primitives (Global) ────────────────
924
+ // In aggregate mode, config reads/writes target ~/.orbital/primitives/
925
+ // Writes propagate to all synced (non-overridden) projects via SyncService.
219
926
 
220
- app.get('/api/orbital/health', (_req, res) => {
221
- res.json({ status: 'ok', uptime: process.uptime(), timestamp: new Date().toISOString() });
927
+ const globalConfigService = new ConfigService(GLOBAL_PRIMITIVES_DIR);
928
+
929
+ app.get('/api/orbital/aggregate/config/:type/tree', (req, res) => {
930
+ const type = req.params.type;
931
+ if (!isValidPrimitiveType(type)) {
932
+ res.status(400).json({ success: false, error: `Invalid type "${type}". Must be one of: agents, skills, hooks` });
933
+ return;
934
+ }
935
+ try {
936
+ const basePath = path.join(GLOBAL_PRIMITIVES_DIR, type);
937
+ const tree = globalConfigService.scanDirectory(basePath);
938
+ res.json({ success: true, data: tree });
939
+ } catch (err) {
940
+ log.error('Config tree read failed', { type, error: String(err) });
941
+ res.status(500).json({ success: false, error: 'Failed to read global config tree' });
942
+ }
222
943
  });
223
944
 
224
- // Serve dynamic config to the frontend
225
- app.get('/api/orbital/config', (_req, res) => {
226
- res.json({
227
- projectName: config.projectName,
228
- categories: config.categories,
229
- agents: config.agents,
230
- serverPort: config.serverPort,
231
- clientPort: config.clientPort,
232
- });
945
+ app.get('/api/orbital/aggregate/config/:type/file', (req, res) => {
946
+ const type = req.params.type;
947
+ if (!isValidPrimitiveType(type)) {
948
+ res.status(400).json({ success: false, error: `Invalid type "${type}". Must be one of: agents, skills, hooks` });
949
+ return;
950
+ }
951
+ const filePath = req.query.path as string | undefined;
952
+ if (!filePath) { res.status(400).json({ success: false, error: 'path query parameter is required' }); return; }
953
+
954
+ try {
955
+ const basePath = path.join(GLOBAL_PRIMITIVES_DIR, type);
956
+ const content = globalConfigService.readFile(basePath, filePath);
957
+ res.json({ success: true, data: { path: filePath, content } });
958
+ } catch (err) {
959
+ const msg = err instanceof Error ? err.message : String(err);
960
+ const status = msg.includes('traversal') ? 403 : msg.includes('ENOENT') || msg.includes('not found') ? 404 : 500;
961
+ res.status(status).json({ success: false, error: msg });
962
+ }
233
963
  });
234
964
 
235
- app.use('/api/orbital', createScopeRoutes({ db, io, scopeService, readinessService, projectRoot: config.projectRoot, engine: workflowEngine }));
236
- app.use('/api/orbital', createDataRoutes({ db, io, gateService, deployService, engine: workflowEngine, projectRoot: config.projectRoot, inferScopeStatus }));
237
- app.use('/api/orbital', createDispatchRoutes({ db, io, scopeService, projectRoot: config.projectRoot, engine: workflowEngine }));
238
- app.use('/api/orbital', createSprintRoutes({ sprintService, sprintOrchestrator, batchOrchestrator }));
239
- app.use('/api/orbital', createWorkflowRoutes({ workflowService, projectRoot: config.projectRoot }));
240
- app.use('/api/orbital', createConfigRoutes({ projectRoot: config.projectRoot, workflowService, io }));
241
- app.use('/api/orbital', createGitRoutes({ gitService, githubService, engine: workflowEngine }));
242
- app.use('/api/orbital', createVersionRoutes({ io }));
965
+ app.put('/api/orbital/aggregate/config/:type/file', (req, res) => {
966
+ const type = req.params.type;
967
+ if (!isValidPrimitiveType(type)) {
968
+ res.status(400).json({ success: false, error: `Invalid type "${type}". Must be one of: agents, skills, hooks` });
969
+ return;
970
+ }
971
+ const { path: filePath, content } = req.body as { path?: string; content?: string };
972
+ if (!filePath || content === undefined) {
973
+ res.status(400).json({ success: false, error: 'path and content are required' });
974
+ return;
975
+ }
243
976
 
244
- // ─── Static File Serving (production) ───────────────────────
977
+ try {
978
+ const basePath = path.join(GLOBAL_PRIMITIVES_DIR, type);
979
+ globalConfigService.writeFile(basePath, filePath, content);
980
+ // Propagate to all synced projects
981
+ const relativePath = path.join(type, filePath);
982
+ const result = syncService.propagateGlobalChange(relativePath);
983
+ io.emit(`config:${type}:changed`, { action: 'updated', path: filePath, global: true });
984
+ res.json({ success: true, propagation: result });
985
+ } catch (err) {
986
+ const msg = err instanceof Error ? err.message : String(err);
987
+ const status = msg.includes('traversal') ? 403 : msg.includes('not found') ? 404 : 500;
988
+ res.status(status).json({ success: false, error: msg });
989
+ }
990
+ });
991
+
992
+ // ─── Static File Serving ─────────────────────────────────
245
993
 
246
- // Resolve the Vite-built frontend dist directory (server/ → ../dist).
247
994
  const __selfDir = path.dirname(fileURLToPath(import.meta.url));
248
995
  const distDir = path.resolve(__selfDir, '../dist');
249
- if (fs.existsSync(path.join(distDir, 'index.html'))) {
996
+ const devMode = clientPort !== port;
997
+ const hasBuiltFrontend = !devMode && fs.existsSync(path.join(distDir, 'index.html'));
998
+ if (hasBuiltFrontend) {
250
999
  app.use(express.static(distDir));
251
1000
  app.get('*', (req, res, next) => {
252
1001
  if (req.path.startsWith('/api/') || req.path.startsWith('/socket.io')) return next();
253
1002
  res.sendFile(path.join(distDir, 'index.html'));
254
1003
  });
255
1004
  } else {
256
- // Dev mode: redirect root to Vite dev server
257
- app.get('/', (_req, res) => res.redirect(`http://localhost:${config.clientPort}`));
1005
+ app.get('/', (_req, res) => res.redirect(`http://localhost:${clientPort}`));
258
1006
  }
259
1007
 
260
- // ─── Socket.io ──────────────────────────────────────────────
1008
+ // ─── Socket.io ───────────────────────────────────────────
261
1009
 
262
1010
  io.on('connection', (socket) => {
263
1011
  log.debug('Client connected', { socketId: socket.id });
264
1012
 
1013
+ socket.on('subscribe', (payload: { projectId?: string; scope?: string }) => {
1014
+ if (payload.scope === 'all') {
1015
+ socket.join('all-projects');
1016
+ } else if (payload.projectId) {
1017
+ socket.join(`project:${payload.projectId}`);
1018
+ }
1019
+ });
1020
+
1021
+ socket.on('unsubscribe', (payload: { projectId?: string; scope?: string }) => {
1022
+ if (payload.scope === 'all') {
1023
+ socket.leave('all-projects');
1024
+ } else if (payload.projectId) {
1025
+ socket.leave(`project:${payload.projectId}`);
1026
+ }
1027
+ });
1028
+
265
1029
  socket.on('disconnect', () => {
266
1030
  log.debug('Client disconnected', { socketId: socket.id });
267
1031
  });
268
1032
  });
269
1033
 
270
- // ─── Startup ───────────────────────────────────────────────
1034
+ // ─── Error Handling Middleware ─────────────────────────────
1035
+ // Catches unhandled errors thrown from route handlers.
1036
+
1037
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1038
+ app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
1039
+ log.error('Unhandled route error', { error: err.message, stack: err.stack });
1040
+ if (!res.headersSent) {
1041
+ res.status(500).json({ ok: false, error: 'Internal server error' });
1042
+ }
1043
+ });
271
1044
 
272
- // References for graceful shutdown
273
- let scopeWatcher: ReturnType<typeof startScopeWatcher>;
274
- let eventWatcher: ReturnType<typeof startEventWatcher>;
275
- let batchRecoveryInterval: ReturnType<typeof setInterval>;
276
- let staleCleanupInterval: ReturnType<typeof setInterval>;
277
- let sessionSyncInterval: ReturnType<typeof setInterval>;
278
- let gitPollInterval: ReturnType<typeof setInterval>;
1045
+ // ─── Start Listening ─────────────────────────────────────
279
1046
 
280
1047
  const actualPort = await new Promise<number>((resolve, reject) => {
281
1048
  let attempt = 0;
@@ -284,9 +1051,7 @@ export async function startServer(overrides?: ServerOverrides): Promise<ServerIn
284
1051
  httpServer.on('error', (err: NodeJS.ErrnoException) => {
285
1052
  if (err.code === 'EADDRINUSE' && attempt < maxAttempts) {
286
1053
  attempt++;
287
- const nextPort = port + attempt;
288
- log.warn('Port in use, trying next', { tried: port + attempt - 1, next: nextPort });
289
- httpServer.listen(nextPort);
1054
+ httpServer.listen(port + attempt);
290
1055
  } else {
291
1056
  reject(new Error(`Failed to start server: ${err.message}`));
292
1057
  }
@@ -294,137 +1059,56 @@ export async function startServer(overrides?: ServerOverrides): Promise<ServerIn
294
1059
 
295
1060
  httpServer.on('listening', () => {
296
1061
  const addr = httpServer.address();
297
- const listenPort = typeof addr === 'object' && addr ? addr.port : port;
298
- resolve(listenPort);
1062
+ resolve(typeof addr === 'object' && addr ? addr.port : port);
299
1063
  });
300
1064
 
301
1065
  httpServer.listen(port);
302
1066
  });
303
1067
 
304
- // ─── Post-listen initialization ────────────────────────────
1068
+ const projectList = projectManager.getProjectList();
1069
+ const projectLines = projectList.map(p =>
1070
+ `║ ${p.status === 'active' ? '●' : '○'} ${p.name.padEnd(20)} ${String(p.scopeCount).padStart(3)} scopes ${p.status.padEnd(8)} ║`
1071
+ ).join('\n');
305
1072
 
306
- // Sync scopes from filesystem on startup (populates in-memory cache)
307
- const scopeCount = scopeService.syncFromFilesystem();
308
-
309
- // Resolve stale dispatch events (terminal scopes + age-based)
310
- const staleResolved = resolveStaleDispatches(db, io, scopeService, workflowEngine);
311
- if (staleResolved > 0) {
312
- log.info('Resolved stale dispatch events', { count: staleResolved });
313
- }
314
-
315
- // Write iTerm2 dispatch profiles (idempotent, fire-and-forget)
316
- ensureDynamicProfiles(workflowEngine);
317
-
318
- // Start file watchers
319
- scopeWatcher = startScopeWatcher(config.scopesDir, scopeService);
320
- eventWatcher = startEventWatcher(config.eventsDir, eventService);
321
-
322
- // Recover any active sprints/batches from before server restart
323
- sprintOrchestrator.recoverActiveSprints().catch(err => log.error('Sprint recovery failed', { error: err.message }));
324
- batchOrchestrator.recoverActiveBatches().catch(err => log.error('Batch recovery failed', { error: err.message }));
325
-
326
- // Resolve stale batches on startup (catches stuck dispatches from previous runs)
327
- const staleBatchesResolved = batchOrchestrator.resolveStaleBatches();
328
- if (staleBatchesResolved > 0) {
329
- log.info('Resolved stale batches', { count: staleBatchesResolved });
330
- }
331
-
332
- // Poll active batch PIDs every 30s for two-phase completion (B-1)
333
- batchRecoveryInterval = setInterval(() => {
334
- batchOrchestrator.recoverActiveBatches().catch(err => log.error('Batch recovery failed', { error: err.message }));
335
- }, 30_000);
336
-
337
- // Periodic stale dispatch + batch cleanup (crash recovery — catches SIGKILL'd sessions)
338
- staleCleanupInterval = setInterval(() => {
339
- const count = resolveStaleDispatches(db, io, scopeService, workflowEngine);
340
- if (count > 0) {
341
- log.info('Periodic cleanup: resolved stale dispatches', { count });
342
- }
343
- const batchCount = batchOrchestrator.resolveStaleBatches();
344
- if (batchCount > 0) {
345
- log.info('Periodic cleanup: resolved stale batches', { count: batchCount });
346
- }
347
- }, 30_000);
348
-
349
- // Sync frontmatter-derived sessions into DB (non-blocking)
350
- syncClaudeSessionsToDB(db, scopeService).then((count) => {
351
- log.info('Synced frontmatter sessions', { count });
352
-
353
- // Purge legacy pattern-matched rows (no action = old regex system)
354
- const purged = db.prepare(
355
- "DELETE FROM sessions WHERE action IS NULL AND id LIKE 'claude-%'"
356
- ).run();
357
- if (purged.changes > 0) {
358
- log.info('Purged legacy pattern-matched session rows', { count: purged.changes });
359
- }
360
- }).catch(err => log.error('Session sync failed', { error: err.message }));
361
-
362
- // Re-sync every 5 minutes so new sessions appear without restart
363
- sessionSyncInterval = setInterval(() => {
364
- syncClaudeSessionsToDB(db, scopeService)
365
- .then((count) => {
366
- if (count > 0) io.emit('session:updated', { type: 'resync', count });
367
- })
368
- .catch(err => log.error('Session resync failed', { error: err.message }));
369
- }, 5 * 60 * 1000);
370
-
371
- // Poll git status every 10s — emit socket event on change
372
- let lastGitHash = '';
373
- gitPollInterval = setInterval(async () => {
374
- try {
375
- const hash = await gitService.getStatusHash();
376
- if (lastGitHash && hash !== lastGitHash) {
377
- gitService.clearCache();
378
- io.emit('git:status:changed');
379
- }
380
- lastGitHash = hash;
381
- } catch { /* ok */ }
382
- }, 10_000);
1073
+ const dashboardPort = devMode ? clientPort : actualPort;
383
1074
 
384
1075
  // eslint-disable-next-line no-console
385
1076
  console.log(`
386
1077
  ╔══════════════════════════════════════════════════════╗
387
- ║ Orbital Command
388
- ║ ${config.projectName.padEnd(42)} ║
1078
+ ║ Orbital Command — Central Server
389
1079
  ║ ║
390
- ║ >>> Open: http://localhost:${actualPort} <<< ║
1080
+ ║ >>> Open: http://localhost:${String(dashboardPort).padEnd(25)} <<<║
391
1081
  ║ ║
392
1082
  ╠══════════════════════════════════════════════════════╣
393
- ║ Scopes: ${String(scopeCount).padEnd(3)} loaded from filesystem ║
1083
+ ${projectLines}
1084
+ ╠══════════════════════════════════════════════════════╣
394
1085
  ║ API: http://localhost:${actualPort}/api/orbital/* ║
395
1086
  ║ Socket.io: ws://localhost:${actualPort} ║
1087
+ ║ Home: ${ORBITAL_HOME.padEnd(39)} ║
396
1088
  ╚══════════════════════════════════════════════════════╝
397
1089
  `);
398
1090
 
399
- // ─── Graceful Shutdown ─────────────────────────────────────
1091
+ // ─── Graceful Shutdown ───────────────────────────────────
400
1092
 
401
1093
  let shuttingDown = false;
402
- function shutdown(): Promise<void> {
403
- if (shuttingDown) return Promise.resolve();
1094
+ async function shutdown(): Promise<void> {
1095
+ if (shuttingDown) return;
404
1096
  shuttingDown = true;
405
- log.info('Shutting down');
406
- scopeWatcher.close();
407
- eventWatcher.close();
408
- clearInterval(batchRecoveryInterval);
409
- clearInterval(staleCleanupInterval);
410
- clearInterval(sessionSyncInterval);
411
- clearInterval(gitPollInterval);
1097
+ log.info('Shutting down central server');
412
1098
 
413
- return new Promise<void>((resolve) => {
414
- const forceTimeout = setTimeout(() => {
415
- closeDatabase();
416
- resolve();
417
- }, 2000);
1099
+ if (globalWatcher) await globalWatcher.close();
1100
+ await projectManager.shutdownAll();
418
1101
 
1102
+ return new Promise<void>((resolve) => {
1103
+ const forceTimeout = setTimeout(resolve, 2000);
419
1104
  io.close(() => {
420
1105
  clearTimeout(forceTimeout);
421
- closeDatabase();
422
1106
  resolve();
423
1107
  });
424
1108
  });
425
1109
  }
426
1110
 
427
- return { app, io, db, workflowEngine, httpServer, shutdown };
1111
+ return { app, io, projectManager, syncService, httpServer, shutdown };
428
1112
  }
429
1113
 
430
1114
  // ─── Direct Execution (backward compat: tsx watch server/index.ts) ───
@@ -436,7 +1120,11 @@ const isDirectRun = process.argv[1] && (
436
1120
  );
437
1121
 
438
1122
  if (isDirectRun) {
439
- startServer().then(({ shutdown }) => {
1123
+ const projectRoot = process.env.ORBITAL_PROJECT_ROOT || process.cwd();
1124
+ startCentralServer({
1125
+ port: Number(process.env.ORBITAL_SERVER_PORT) || 4444,
1126
+ autoRegisterPath: projectRoot,
1127
+ }).then(({ shutdown }) => {
440
1128
  process.on('SIGINT', async () => {
441
1129
  await shutdown();
442
1130
  process.exit(0);