orbital-command 0.1.4 → 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 +676 -53
  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 -49
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import matter from 'gray-matter';
4
- import { normalizeStatus, parseAllScopes, parseScopeFile, setValidStatuses } from '../parsers/scope-parser.js';
4
+ import { parseAllScopes, parseScopeFile, setValidStatuses } from '../parsers/scope-parser.js';
5
5
  import { createLogger } from '../utils/logger.js';
6
6
  const log = createLogger('scope');
7
7
  export class ScopeService {
@@ -38,6 +38,44 @@ export class ScopeService {
38
38
  this.cache.loadAll(scopes);
39
39
  return scopes.length;
40
40
  }
41
+ /** Reconcile files whose directory doesn't match their frontmatter status.
42
+ * Frontmatter is the authoritative source — files are moved to match it.
43
+ * Called once at startup after syncFromFilesystem(). */
44
+ reconcileDirectories() {
45
+ let moved = 0;
46
+ for (const scope of this.cache.getAll()) {
47
+ if (scope.id < 0)
48
+ continue; // slug-only icebox items (negative IDs)
49
+ const currentDir = path.basename(path.dirname(scope.file_path));
50
+ if (currentDir === scope.status)
51
+ continue;
52
+ if (!this.engine.isValidStatus(scope.status))
53
+ continue;
54
+ const targetDir = path.join(this.scopesDir, scope.status);
55
+ if (!fs.existsSync(targetDir))
56
+ fs.mkdirSync(targetDir, { recursive: true });
57
+ const newPath = path.join(targetDir, path.basename(scope.file_path));
58
+ this.suppressedPaths.add(scope.file_path);
59
+ this.suppressedPaths.add(newPath);
60
+ try {
61
+ fs.renameSync(scope.file_path, newPath);
62
+ this.updateFromFile(newPath);
63
+ this.removeByFilePath(scope.file_path);
64
+ moved++;
65
+ log.warn('Reconciled directory mismatch', {
66
+ id: scope.id, frontmatter: scope.status, directory: currentDir,
67
+ });
68
+ }
69
+ catch (err) {
70
+ log.error('Failed to reconcile scope directory', { id: scope.id, error: String(err) });
71
+ }
72
+ setTimeout(() => {
73
+ this.suppressedPaths.delete(scope.file_path);
74
+ this.suppressedPaths.delete(newPath);
75
+ }, 2000);
76
+ }
77
+ return moved;
78
+ }
41
79
  /** Check if a path is suppressed from watcher processing (during programmatic moves) */
42
80
  isSuppressed(filePath) {
43
81
  return this.suppressedPaths.has(filePath);
@@ -89,6 +127,7 @@ export class ScopeService {
89
127
  }
90
128
  /** Update a scope's status with transition validation.
91
129
  * Writes the new status to the frontmatter file and updates the cache.
130
+ * This is the SINGLE validation point — all status changes must flow through here.
92
131
  * @param context - caller trust level: 'patch', 'dispatch', 'event', 'bulk-sync', 'rollback' */
93
132
  updateStatus(id, status, context = 'patch') {
94
133
  if (!this.engine.isValidStatus(status)) {
@@ -111,12 +150,11 @@ export class ScopeService {
111
150
  if (!check.ok)
112
151
  return check;
113
152
  }
114
- // Write to filesystem via updateScopeFrontmatter (which updates cache + emits)
115
- const current = context === 'bulk-sync' || context === 'rollback'
116
- ? this.cache.getById(id)
117
- : this.cache.getById(id); // already fetched above for validation, but may be null in bulk-sync
153
+ // Fetch current scope for fromStatus logging. In bulk-sync/rollback contexts
154
+ // the validation block above is skipped, so this may be the first lookup.
155
+ const current = this.cache.getById(id);
118
156
  const fromStatus = current?.status ?? 'unknown';
119
- const result = this.updateScopeFrontmatter(id, { status }, context);
157
+ const result = this._writeFrontmatter(id, { status });
120
158
  if (result.ok) {
121
159
  log.info('Status updated', { id, from: fromStatus, to: status, context });
122
160
  for (const cb of this.onStatusChangeCallbacks)
@@ -124,8 +162,31 @@ export class ScopeService {
124
162
  }
125
163
  return result;
126
164
  }
165
+ /** Update scope fields via a public API (e.g. PATCH route).
166
+ * Status changes are routed through updateStatus for validation.
167
+ * Non-status fields are written directly via _writeFrontmatter. */
168
+ updateFields(id, fields) {
169
+ const { status, ...nonStatusFields } = fields;
170
+ // Status changes go through updateStatus (validates transition, fires callbacks)
171
+ if (status) {
172
+ const current = this.cache.getById(id);
173
+ if (!current)
174
+ return { ok: false, error: 'Scope not found', code: 'NOT_FOUND' };
175
+ if (status !== current.status) {
176
+ const result = this.updateStatus(id, status);
177
+ if (!result.ok)
178
+ return result;
179
+ }
180
+ }
181
+ // Non-status field updates written directly
182
+ if (Object.keys(nonStatusFields).length > 0) {
183
+ return this._writeFrontmatter(id, nonStatusFields);
184
+ }
185
+ return { ok: true, moved: !!status };
186
+ }
127
187
  /** Compute the next sequential scope ID by scanning all non-icebox scopes.
128
- * Checks both filesystem (all subdirs except icebox) and cache to prevent collisions. */
188
+ * Checks both filesystem (all subdirs except icebox) and cache to prevent collisions.
189
+ * Skips IDs >= 500 to handle legacy icebox-origin files during migration. */
129
190
  getNextScopeId() {
130
191
  let maxId = 0;
131
192
  // Scan all scope subdirectories except icebox
@@ -136,8 +197,13 @@ export class ScopeService {
136
197
  const dirPath = path.join(this.scopesDir, dir.name);
137
198
  for (const file of fs.readdirSync(dirPath)) {
138
199
  const m = file.match(/^(\d+)-/);
139
- if (m)
140
- maxId = Math.max(maxId, parseInt(m[1], 10));
200
+ if (!m)
201
+ continue;
202
+ const id = parseInt(m[1], 10);
203
+ // Skip legacy icebox-origin IDs (500+) to prevent namespace pollution
204
+ if (id >= 500)
205
+ continue;
206
+ maxId = Math.max(maxId, id);
141
207
  }
142
208
  }
143
209
  }
@@ -147,50 +213,61 @@ export class ScopeService {
147
213
  return maxId + 1;
148
214
  }
149
215
  // ─── Idea CRUD (filesystem-backed icebox cards) ────────────
150
- /** Get the next available icebox ID (starts at 501, increments from max found) */
151
- getNextIceboxId() {
152
- const iceboxDir = path.join(this.scopesDir, 'icebox');
153
- if (!fs.existsSync(iceboxDir))
154
- return 501;
155
- let maxId = 500;
156
- for (const file of fs.readdirSync(iceboxDir)) {
157
- const m = file.match(/^(\d+)-/);
158
- if (m)
159
- maxId = Math.max(maxId, parseInt(m[1], 10));
216
+ /** Normalize Date objects in gray-matter frontmatter to YYYY-MM-DD strings */
217
+ normalizeFrontmatterDates(data) {
218
+ for (const key of Object.keys(data)) {
219
+ if (data[key] instanceof Date) {
220
+ data[key] = data[key].toISOString().split('T')[0];
221
+ }
160
222
  }
161
- return maxId + 1;
162
223
  }
163
- /** Find an icebox file by its ID prefix.
164
- * Matches both padded (091-) and unpadded (91-) filenames
165
- * since demoted scopes keep their 3-digit-padded names. */
166
- findIdeaFile(iceboxDir, id) {
224
+ /** Generate a slug from a title */
225
+ slugify(title) {
226
+ const slug = title
227
+ .toLowerCase()
228
+ .replace(/[^a-z0-9]+/g, '-')
229
+ .replace(/^-|-$/g, '')
230
+ .slice(0, 60);
231
+ if (!slug)
232
+ return 'untitled';
233
+ return slug;
234
+ }
235
+ /** Find an icebox file by its slug.
236
+ * Matches slug-only files ({slug}.md) and legacy numeric-prefixed files ({NNN}-{slug}.md). */
237
+ findIdeaFile(iceboxDir, slug) {
167
238
  if (!fs.existsSync(iceboxDir))
168
239
  return null;
169
240
  const match = fs.readdirSync(iceboxDir).find((f) => {
170
241
  if (!f.endsWith('.md'))
171
242
  return false;
172
- const m = f.match(/^(\d+)-/);
173
- return m != null && parseInt(m[1], 10) === id;
243
+ // Match slug-only: {slug}.md
244
+ if (f === `${slug}.md`)
245
+ return true;
246
+ // Match legacy numeric-prefixed: {NNN}-{slug}.md
247
+ return f.match(/^\d+-/) && f.slice(f.indexOf('-') + 1) === `${slug}.md`;
174
248
  });
175
249
  return match ? path.join(iceboxDir, match) : null;
176
250
  }
177
- /** Create an icebox idea as a markdown file. IDs start at 501. */
251
+ /** Create an icebox idea as a slug-only markdown file. */
178
252
  createIdeaFile(title, description) {
179
253
  const iceboxDir = path.join(this.scopesDir, 'icebox');
180
254
  if (!fs.existsSync(iceboxDir))
181
255
  fs.mkdirSync(iceboxDir, { recursive: true });
182
- const nextId = this.getNextIceboxId();
183
- const slug = title
184
- .toLowerCase()
185
- .replace(/[^a-z0-9]+/g, '-')
186
- .replace(/^-|-$/g, '')
187
- .slice(0, 60);
188
- const fileName = `${nextId}-${slug}.md`;
189
- const filePath = path.join(iceboxDir, fileName);
256
+ const slug = this.slugify(title);
257
+ let fileName = `${slug}.md`;
258
+ let filePath = path.join(iceboxDir, fileName);
259
+ // Handle slug collisions by appending -2, -3, etc.
260
+ if (fs.existsSync(filePath)) {
261
+ let suffix = 2;
262
+ while (fs.existsSync(path.join(iceboxDir, `${slug}-${suffix}.md`)))
263
+ suffix++;
264
+ fileName = `${slug}-${suffix}.md`;
265
+ filePath = path.join(iceboxDir, fileName);
266
+ }
267
+ const finalSlug = fileName.replace(/\.md$/, '');
190
268
  const now = new Date().toISOString().split('T')[0];
191
269
  const content = [
192
270
  '---',
193
- `id: ${nextId}`,
194
271
  `title: "${title.replace(/"/g, '\\"')}"`,
195
272
  'status: icebox',
196
273
  `created: ${now}`,
@@ -206,105 +283,124 @@ export class ScopeService {
206
283
  fs.writeFileSync(filePath, content, 'utf-8');
207
284
  // Eagerly sync to cache + emit scope:created
208
285
  this.updateFromFile(filePath);
209
- log.info('Idea created', { id: nextId, title });
210
- return { id: nextId, title };
286
+ log.info('Idea created', { slug: finalSlug, title });
287
+ return { slug: finalSlug, title };
211
288
  }
212
- /** Update an icebox idea's title and description by rewriting its file */
213
- updateIdeaFile(id, title, description) {
289
+ /** Update an icebox idea's title and description in-place. Renames the file if the title slug changes. */
290
+ updateIdeaFile(slug, title, description) {
214
291
  const iceboxDir = path.join(this.scopesDir, 'icebox');
215
- const filePath = this.findIdeaFile(iceboxDir, id);
292
+ const filePath = this.findIdeaFile(iceboxDir, slug);
216
293
  if (!filePath)
217
294
  return false;
218
295
  // Preserve the original created date from existing frontmatter
219
296
  const existing = fs.readFileSync(filePath, 'utf-8');
220
- const createdMatch = existing.match(/^created:\s*(.+)$/m);
221
- const created = createdMatch?.[1]?.trim() ?? new Date().toISOString().split('T')[0];
297
+ const parsed = matter(existing);
298
+ const created = parsed.data.created ? String(parsed.data.created) : new Date().toISOString().split('T')[0];
222
299
  const now = new Date().toISOString().split('T')[0];
223
- const content = [
224
- '---',
225
- `id: ${id}`,
226
- `title: "${title.replace(/"/g, '\\"')}"`,
227
- 'status: icebox',
228
- `created: ${created}`,
229
- `updated: ${now}`,
230
- 'blocked_by: []',
231
- 'blocks: []',
232
- 'tags: []',
233
- '---',
234
- '',
235
- description || '',
236
- '',
237
- ].join('\n');
238
- fs.writeFileSync(filePath, content, 'utf-8');
239
- // Watcher handles cache sync + scope:updated event
300
+ // Update frontmatter fields while preserving other data (like ghost)
301
+ parsed.data.title = title;
302
+ parsed.data.updated = now;
303
+ parsed.data.created = created;
304
+ this.normalizeFrontmatterDates(parsed.data);
305
+ const newContent = matter.stringify(description ? `\n${description}\n` : '\n', parsed.data);
306
+ fs.writeFileSync(filePath, newContent, 'utf-8');
307
+ // If title changed, rename file to new slug
308
+ const newSlug = this.slugify(title);
309
+ if (newSlug !== slug) {
310
+ const newFileName = `${newSlug}.md`;
311
+ const newPath = path.join(iceboxDir, newFileName);
312
+ if (!fs.existsSync(newPath)) {
313
+ this.suppressedPaths.add(filePath);
314
+ this.suppressedPaths.add(newPath);
315
+ this.removeByFilePath(filePath);
316
+ fs.renameSync(filePath, newPath);
317
+ this.updateFromFile(newPath);
318
+ setTimeout(() => {
319
+ this.suppressedPaths.delete(filePath);
320
+ this.suppressedPaths.delete(newPath);
321
+ }, 2000);
322
+ }
323
+ else {
324
+ // Collision with existing slug — keep old filename, still sync content changes
325
+ log.warn('Slug collision during rename, keeping old filename', { slug, newSlug });
326
+ this.updateFromFile(filePath);
327
+ }
328
+ }
329
+ else {
330
+ // Eagerly sync content changes to cache
331
+ this.updateFromFile(filePath);
332
+ }
240
333
  return true;
241
334
  }
242
335
  /** Delete an icebox idea by removing its file */
243
- deleteIdeaFile(id) {
336
+ deleteIdeaFile(slug) {
244
337
  const iceboxDir = path.join(this.scopesDir, 'icebox');
245
- const filePath = this.findIdeaFile(iceboxDir, id);
338
+ const filePath = this.findIdeaFile(iceboxDir, slug);
246
339
  if (!filePath)
247
340
  return false;
248
341
  fs.unlinkSync(filePath);
249
342
  // Eagerly remove from cache + emit scope:deleted
250
343
  this.removeByFilePath(filePath);
251
- log.info('Idea deleted', { id });
344
+ log.info('Idea deleted', { slug });
252
345
  return true;
253
346
  }
254
347
  /** Promote an icebox idea to planning — assigns a proper sequential scope ID,
255
348
  * moves the file, and syncs cache. Returns the new scope ID. */
256
- promoteIdea(id) {
349
+ promoteIdea(slug) {
257
350
  const iceboxDir = path.join(this.scopesDir, 'icebox');
258
- const oldPath = this.findIdeaFile(iceboxDir, id);
351
+ const oldPath = this.findIdeaFile(iceboxDir, slug);
259
352
  if (!oldPath)
260
353
  return null;
261
354
  // Read existing file for metadata
262
- const content = fs.readFileSync(oldPath, 'utf-8');
263
- const titleMatch = content.match(/^title:\s*"?([^"\n]+)"?\s*$/m);
264
- const createdMatch = content.match(/^created:\s*(.+)$/m);
265
- const title = titleMatch?.[1]?.trim() ?? 'Untitled';
266
- const created = createdMatch?.[1]?.trim() ?? new Date().toISOString().split('T')[0];
267
- // Extract body after frontmatter
268
- const fmEnd = content.indexOf('---', content.indexOf('---') + 3);
269
- const description = fmEnd !== -1 ? content.slice(fmEnd + 3).trim() : '';
355
+ const raw = fs.readFileSync(oldPath, 'utf-8');
356
+ const parsed = matter(raw);
357
+ const title = parsed.data.title ? String(parsed.data.title) : 'Untitled';
358
+ const created = parsed.data.created ? String(parsed.data.created) : new Date().toISOString().split('T')[0];
359
+ const description = parsed.content.trim();
270
360
  // Assign the next sequential scope ID (excludes icebox items)
271
361
  const newId = this.getNextScopeId();
272
362
  const paddedId = String(newId).padStart(3, '0');
273
- // Build slug and new path
274
- const slug = title
275
- .toLowerCase()
276
- .replace(/[^a-z0-9]+/g, '-')
277
- .replace(/^-|-$/g, '')
278
- .slice(0, 60);
363
+ // Build new path
364
+ const titleSlug = this.slugify(title);
279
365
  const planningDir = path.join(this.scopesDir, 'planning');
280
366
  if (!fs.existsSync(planningDir))
281
367
  fs.mkdirSync(planningDir, { recursive: true });
282
- const newFileName = `${paddedId}-${slug}.md`;
368
+ const newFileName = `${paddedId}-${titleSlug}.md`;
283
369
  const newPath = path.join(planningDir, newFileName);
284
370
  const now = new Date().toISOString().split('T')[0];
285
- // Write new file with planning status and new sequential ID
286
- const newContent = [
287
- '---',
288
- `id: ${paddedId}`,
289
- `title: "${title.replace(/"/g, '\\"')}"`,
290
- 'status: planning',
291
- `created: ${created}`,
292
- `updated: ${now}`,
293
- 'blocked_by: []',
294
- 'blocks: []',
295
- 'tags: []',
296
- '---',
297
- '',
298
- description || '',
299
- '',
300
- ].join('\n');
301
- fs.writeFileSync(newPath, newContent, 'utf-8');
302
- // Sync cache before deleting old file (avoids window where scope is missing)
371
+ // Update frontmatter in-place: assign ID and change status (preserve other fields)
372
+ parsed.data.id = newId;
373
+ parsed.data.status = 'planning';
374
+ parsed.data.updated = now;
375
+ parsed.data.created = created;
376
+ delete parsed.data.ghost;
377
+ this.normalizeFrontmatterDates(parsed.data);
378
+ const newContent = matter.stringify(parsed.content, parsed.data);
379
+ // Write updated content to old path, then rename/move (no intermediate missing state)
380
+ const originalContent = fs.readFileSync(oldPath, 'utf-8');
381
+ fs.writeFileSync(oldPath, newContent, 'utf-8');
382
+ // Suppress watcher events during programmatic move
383
+ this.suppressedPaths.add(oldPath);
384
+ this.suppressedPaths.add(newPath);
385
+ try {
386
+ fs.renameSync(oldPath, newPath);
387
+ }
388
+ catch (err) {
389
+ // Restore original content on rename failure
390
+ fs.writeFileSync(oldPath, originalContent, 'utf-8');
391
+ this.suppressedPaths.delete(oldPath);
392
+ this.suppressedPaths.delete(newPath);
393
+ log.error('Failed to rename during promote', { oldPath, newPath, error: String(err) });
394
+ return null;
395
+ }
303
396
  this.updateFromFile(newPath);
304
- fs.unlinkSync(oldPath);
305
397
  this.removeByFilePath(oldPath);
398
+ setTimeout(() => {
399
+ this.suppressedPaths.delete(oldPath);
400
+ this.suppressedPaths.delete(newPath);
401
+ }, 2000);
306
402
  const relPath = path.relative(path.resolve(this.scopesDir, '..'), newPath);
307
- log.info('Idea promoted', { oldId: id, newId, title });
403
+ log.info('Idea promoted', { slug, newId, title });
308
404
  return { id: newId, filePath: relPath, title, description };
309
405
  }
310
406
  /** Find a scope file by its numeric ID prefix across all status directories */
@@ -325,10 +421,11 @@ export class ScopeService {
325
421
  }
326
422
  return null;
327
423
  }
328
- /** Update a scope's frontmatter fields and write back to the .md file.
329
- * If status changes, validates the transition and moves the file to the new status directory.
330
- * @param context - transition context for validation (default 'patch') */
331
- updateScopeFrontmatter(id, fields, context = 'patch') {
424
+ /** Write frontmatter fields to a scope's .md file.
425
+ * If the effective status differs from the current directory, moves the file.
426
+ * This is a trusted write operation callers must validate transitions
427
+ * via updateStatus() before calling this method with status changes. */
428
+ _writeFrontmatter(id, fields) {
332
429
  const filePath = this.findScopeFile(id);
333
430
  if (!filePath) {
334
431
  return { ok: false, error: 'Scope file not found', code: 'NOT_FOUND' };
@@ -336,25 +433,19 @@ export class ScopeService {
336
433
  const raw = fs.readFileSync(filePath, 'utf-8');
337
434
  const parsed = matter(raw);
338
435
  const today = new Date().toISOString().split('T')[0];
339
- // Validate status transition before any writes
436
+ // Determine if the file needs to move to a different directory.
437
+ // Compare against the DIRECTORY name (not frontmatter status) since the question
438
+ // is whether the physical file location matches the desired status.
340
439
  const newStatus = fields.status;
341
- const rawOldStatus = String(parsed.data.status ?? 'planning');
342
- const oldStatus = normalizeStatus(rawOldStatus);
343
- let needsMove = false;
344
- if (newStatus && newStatus !== oldStatus) {
345
- if (!this.engine.isValidStatus(newStatus)) {
346
- return { ok: false, error: `Invalid status: '${newStatus}'`, code: 'INVALID_STATUS' };
347
- }
348
- const check = this.engine.validateTransition(oldStatus, newStatus, context);
349
- if (!check.ok)
350
- return check;
351
- needsMove = true;
352
- // Auto-unlock spec when reverting backlog → planning
353
- if (newStatus === 'planning' && oldStatus === 'backlog')
354
- fields.spec_locked = false;
440
+ const dirName = path.basename(path.dirname(filePath));
441
+ const effectiveStatus = newStatus ?? String(parsed.data.status ?? dirName);
442
+ const needsMove = effectiveStatus !== dirName && this.engine.isValidStatus(effectiveStatus);
443
+ // Auto-unlock spec when reverting backlog → planning
444
+ if (newStatus === 'planning' && dirName === 'backlog') {
445
+ fields = { ...fields, spec_locked: false };
355
446
  }
356
447
  // Merge editable fields into frontmatter
357
- const editableKeys = ['title', 'status', 'priority', 'effort_estimate', 'category', 'tags', 'blocked_by', 'blocks', 'spec_locked'];
448
+ const editableKeys = ['title', 'status', 'priority', 'effort_estimate', 'category', 'tags', 'blocked_by', 'blocks', 'spec_locked', 'favourite'];
358
449
  for (const key of editableKeys) {
359
450
  if (key in fields) {
360
451
  const val = fields[key];
@@ -384,8 +475,8 @@ export class ScopeService {
384
475
  log.info('Frontmatter updated', { id, fields: Object.keys(fields) });
385
476
  return { ok: true };
386
477
  }
387
- // Status change → move file to new directory
388
- const targetDir = path.join(this.scopesDir, newStatus);
478
+ // Status differs from directory → move file to correct directory
479
+ const targetDir = path.join(this.scopesDir, effectiveStatus);
389
480
  if (!fs.existsSync(targetDir))
390
481
  fs.mkdirSync(targetDir, { recursive: true });
391
482
  const fileName = path.basename(filePath);
@@ -403,13 +494,13 @@ export class ScopeService {
403
494
  setTimeout(() => {
404
495
  this.suppressedPaths.delete(filePath);
405
496
  this.suppressedPaths.delete(newPath);
406
- }, 500);
497
+ }, 2000);
407
498
  return { ok: true, moved: true };
408
499
  }
409
500
  /** Approve a ghost idea — removes ghost:true from frontmatter and refreshes cache */
410
- approveGhostIdea(id) {
501
+ approveGhostIdea(slug) {
411
502
  const iceboxDir = path.join(this.scopesDir, 'icebox');
412
- const filePath = this.findIdeaFile(iceboxDir, id);
503
+ const filePath = this.findIdeaFile(iceboxDir, slug);
413
504
  if (!filePath)
414
505
  return false;
415
506
  const content = fs.readFileSync(filePath, 'utf-8');
@@ -418,7 +509,7 @@ export class ScopeService {
418
509
  fs.writeFileSync(filePath, updated, 'utf-8');
419
510
  // Re-parse file to refresh cache with is_ghost=false
420
511
  this.updateFromFile(filePath);
421
- log.info('Ghost approved', { id });
512
+ log.info('Ghost approved', { slug });
422
513
  return true;
423
514
  }
424
515
  }
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { ScopeService } from './scope-service.js';
3
+ import { ScopeCache } from './scope-cache.js';
4
+ import { WorkflowEngine } from '../../shared/workflow-engine.js';
5
+ import { CONFIG_WITH_HOOKS } from '../../shared/__fixtures__/workflow-configs.js';
6
+ import { createMockEmitter } from '../__tests__/helpers/mock-emitter.js';
7
+ import fs from 'fs';
8
+ import os from 'os';
9
+ import path from 'path';
10
+ describe('ScopeService', () => {
11
+ let tmpDir;
12
+ let cache;
13
+ let emitter;
14
+ let engine;
15
+ let service;
16
+ beforeEach(() => {
17
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'scope-svc-test-'));
18
+ cache = new ScopeCache();
19
+ emitter = createMockEmitter();
20
+ engine = new WorkflowEngine(CONFIG_WITH_HOOKS);
21
+ service = new ScopeService(cache, emitter, tmpDir, engine);
22
+ // Create status directories
23
+ for (const status of ['icebox', 'backlog', 'active', 'review', 'shipped']) {
24
+ fs.mkdirSync(path.join(tmpDir, status), { recursive: true });
25
+ }
26
+ });
27
+ afterEach(() => {
28
+ fs.rmSync(tmpDir, { recursive: true, force: true });
29
+ });
30
+ function writeScopeFile(status, filename, frontmatter, body = '') {
31
+ const filePath = path.join(tmpDir, status, filename);
32
+ const yamlLines = Object.entries(frontmatter).map(([k, v]) => {
33
+ if (Array.isArray(v))
34
+ return `${k}: [${v.join(', ')}]`;
35
+ return `${k}: ${v}`;
36
+ }).join('\n');
37
+ fs.writeFileSync(filePath, `---\n${yamlLines}\n---\n${body}\n`);
38
+ return filePath;
39
+ }
40
+ // ─── syncFromFilesystem() ─────────────────────────────────
41
+ describe('syncFromFilesystem()', () => {
42
+ it('loads all .md files into cache', () => {
43
+ writeScopeFile('backlog', '001-first.md', { title: 'First', status: 'backlog' });
44
+ writeScopeFile('active', '002-second.md', { title: 'Second', status: 'active' });
45
+ const count = service.syncFromFilesystem();
46
+ expect(count).toBe(2);
47
+ expect(cache.size).toBe(2);
48
+ });
49
+ it('returns 0 for empty directories', () => {
50
+ expect(service.syncFromFilesystem()).toBe(0);
51
+ });
52
+ });
53
+ // ─── getAll() / getById() ─────────────────────────────────
54
+ describe('getAll() / getById()', () => {
55
+ it('delegates to cache', () => {
56
+ writeScopeFile('backlog', '001-test.md', { title: 'Test', status: 'backlog' });
57
+ service.syncFromFilesystem();
58
+ expect(service.getAll()).toHaveLength(1);
59
+ expect(service.getById(1)?.title).toBe('Test');
60
+ expect(service.getById(999)).toBeUndefined();
61
+ });
62
+ });
63
+ // ─── updateStatus() ──────────────────────────────────────
64
+ describe('updateStatus()', () => {
65
+ beforeEach(() => {
66
+ writeScopeFile('backlog', '001-test.md', { title: 'Test', status: 'backlog' });
67
+ service.syncFromFilesystem();
68
+ });
69
+ it('validates transition via engine', () => {
70
+ // backlog → shipped is not a valid edge in CONFIG_WITH_HOOKS
71
+ const result = service.updateStatus(1, 'shipped', 'patch');
72
+ expect(result.ok).toBe(false);
73
+ });
74
+ it('bulk-sync bypasses validation', () => {
75
+ const result = service.updateStatus(1, 'shipped', 'bulk-sync');
76
+ expect(result.ok).toBe(true);
77
+ });
78
+ it('returns NOT_FOUND for unknown scope', () => {
79
+ const result = service.updateStatus(999, 'active', 'dispatch');
80
+ expect(result.ok).toBe(false);
81
+ });
82
+ it('fires onStatusChange callbacks on successful transition', () => {
83
+ const callback = vi.fn();
84
+ service.onStatusChange(callback);
85
+ // Use bulk-sync to bypass edge validation — focuses on the callback mechanism
86
+ const result = service.updateStatus(1, 'active', 'bulk-sync');
87
+ if (result.ok) {
88
+ expect(callback).toHaveBeenCalledWith(1, 'active');
89
+ }
90
+ });
91
+ });
92
+ // ─── createIdeaFile() ─────────────────────────────────────
93
+ describe('createIdeaFile()', () => {
94
+ it('creates file in icebox directory', () => {
95
+ const result = service.createIdeaFile('My New Idea', 'A description of the idea');
96
+ expect(result.slug).toBeDefined();
97
+ expect(result.title).toBe('My New Idea');
98
+ // Verify file exists
99
+ const files = fs.readdirSync(path.join(tmpDir, 'icebox'));
100
+ expect(files.length).toBe(1);
101
+ expect(files[0]).toMatch(/\.md$/);
102
+ });
103
+ it('slugifies the title', () => {
104
+ const result = service.createIdeaFile('Some Feature Idea!', '');
105
+ expect(result.slug).toMatch(/^[a-z0-9-]+$/);
106
+ });
107
+ });
108
+ // ─── deleteIdeaFile() ─────────────────────────────────────
109
+ describe('deleteIdeaFile()', () => {
110
+ it('removes the idea file', () => {
111
+ const { slug } = service.createIdeaFile('To Delete', '');
112
+ const result = service.deleteIdeaFile(slug);
113
+ expect(result).toBe(true);
114
+ const files = fs.readdirSync(path.join(tmpDir, 'icebox'));
115
+ expect(files).toHaveLength(0);
116
+ });
117
+ it('returns false for non-existent slug', () => {
118
+ expect(service.deleteIdeaFile('nonexistent-slug')).toBe(false);
119
+ });
120
+ });
121
+ // ─── reconcileDirectories() ───────────────────────────────
122
+ describe('reconcileDirectories()', () => {
123
+ it('moves files to correct directory based on frontmatter', () => {
124
+ // Put a file in backlog but with status: active in frontmatter
125
+ writeScopeFile('backlog', '001-misplaced.md', { title: 'Misplaced', status: 'active' });
126
+ service.syncFromFilesystem();
127
+ const moved = service.reconcileDirectories();
128
+ expect(moved).toBeGreaterThanOrEqual(0);
129
+ });
130
+ });
131
+ // ─── isSuppressed() ──────────────────────────────────────
132
+ describe('isSuppressed()', () => {
133
+ it('returns false by default', () => {
134
+ expect(service.isSuppressed('/some/path.md')).toBe(false);
135
+ });
136
+ });
137
+ });