orbital-command 0.1.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 (325) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +396 -0
  3. package/bin/orbital.js +362 -0
  4. package/dist/assets/WorkflowVisualizer-BZ21PIIF.js +84 -0
  5. package/dist/assets/WorkflowVisualizer-BZV40eAE.css +1 -0
  6. package/dist/assets/charts-D__PA1zp.js +72 -0
  7. package/dist/assets/index-D1G6i0nS.css +1 -0
  8. package/dist/assets/index-DpItvKpf.js +419 -0
  9. package/dist/assets/ui-BvF022GT.js +53 -0
  10. package/dist/assets/vendor-Dzv9lrRc.js +59 -0
  11. package/dist/index.html +19 -0
  12. package/dist/scanner-sweep.png +0 -0
  13. package/dist/server/server/adapters/index.js +34 -0
  14. package/dist/server/server/adapters/iterm2-adapter.js +29 -0
  15. package/dist/server/server/adapters/subprocess-adapter.js +21 -0
  16. package/dist/server/server/adapters/terminal-adapter.js +1 -0
  17. package/dist/server/server/config.js +156 -0
  18. package/dist/server/server/database.js +90 -0
  19. package/dist/server/server/index.js +372 -0
  20. package/dist/server/server/init.js +811 -0
  21. package/dist/server/server/parsers/event-parser.js +64 -0
  22. package/dist/server/server/parsers/scope-parser.js +188 -0
  23. package/dist/server/server/routes/config-routes.js +163 -0
  24. package/dist/server/server/routes/data-routes.js +461 -0
  25. package/dist/server/server/routes/dispatch-routes.js +215 -0
  26. package/dist/server/server/routes/git-routes.js +92 -0
  27. package/dist/server/server/routes/scope-routes.js +215 -0
  28. package/dist/server/server/routes/sprint-routes.js +116 -0
  29. package/dist/server/server/routes/version-routes.js +130 -0
  30. package/dist/server/server/routes/workflow-routes.js +185 -0
  31. package/dist/server/server/schema.js +90 -0
  32. package/dist/server/server/services/batch-orchestrator.js +253 -0
  33. package/dist/server/server/services/claude-session-service.js +352 -0
  34. package/dist/server/server/services/config-service.js +132 -0
  35. package/dist/server/server/services/deploy-service.js +51 -0
  36. package/dist/server/server/services/event-service.js +63 -0
  37. package/dist/server/server/services/gate-service.js +83 -0
  38. package/dist/server/server/services/git-service.js +309 -0
  39. package/dist/server/server/services/github-service.js +145 -0
  40. package/dist/server/server/services/readiness-service.js +184 -0
  41. package/dist/server/server/services/scope-cache.js +72 -0
  42. package/dist/server/server/services/scope-service.js +424 -0
  43. package/dist/server/server/services/sprint-orchestrator.js +312 -0
  44. package/dist/server/server/services/sprint-service.js +293 -0
  45. package/dist/server/server/services/workflow-service.js +397 -0
  46. package/dist/server/server/utils/cc-hooks-parser.js +49 -0
  47. package/dist/server/server/utils/dispatch-utils.js +305 -0
  48. package/dist/server/server/utils/logger.js +86 -0
  49. package/dist/server/server/utils/terminal-launcher.js +388 -0
  50. package/dist/server/server/utils/worktree-manager.js +98 -0
  51. package/dist/server/server/watchers/event-watcher.js +81 -0
  52. package/dist/server/server/watchers/scope-watcher.js +33 -0
  53. package/dist/server/shared/api-types.js +5 -0
  54. package/dist/server/shared/default-workflow.json +616 -0
  55. package/dist/server/shared/workflow-config.js +44 -0
  56. package/dist/server/shared/workflow-engine.js +353 -0
  57. package/index.html +15 -0
  58. package/package.json +110 -0
  59. package/postcss.config.js +6 -0
  60. package/schemas/orbital.config.schema.json +83 -0
  61. package/scripts/postinstall.js +24 -0
  62. package/scripts/start.sh +20 -0
  63. package/server/adapters/index.ts +41 -0
  64. package/server/adapters/iterm2-adapter.ts +37 -0
  65. package/server/adapters/subprocess-adapter.ts +25 -0
  66. package/server/adapters/terminal-adapter.ts +24 -0
  67. package/server/config.ts +234 -0
  68. package/server/database.ts +107 -0
  69. package/server/index.ts +452 -0
  70. package/server/init.ts +891 -0
  71. package/server/parsers/event-parser.ts +74 -0
  72. package/server/parsers/scope-parser.ts +240 -0
  73. package/server/routes/config-routes.ts +182 -0
  74. package/server/routes/data-routes.ts +548 -0
  75. package/server/routes/dispatch-routes.ts +275 -0
  76. package/server/routes/git-routes.ts +112 -0
  77. package/server/routes/scope-routes.ts +262 -0
  78. package/server/routes/sprint-routes.ts +142 -0
  79. package/server/routes/version-routes.ts +156 -0
  80. package/server/routes/workflow-routes.ts +198 -0
  81. package/server/schema.ts +90 -0
  82. package/server/services/batch-orchestrator.ts +286 -0
  83. package/server/services/claude-session-service.ts +441 -0
  84. package/server/services/config-service.ts +151 -0
  85. package/server/services/deploy-service.ts +98 -0
  86. package/server/services/event-service.ts +98 -0
  87. package/server/services/gate-service.ts +126 -0
  88. package/server/services/git-service.ts +391 -0
  89. package/server/services/github-service.ts +183 -0
  90. package/server/services/readiness-service.ts +250 -0
  91. package/server/services/scope-cache.ts +81 -0
  92. package/server/services/scope-service.ts +476 -0
  93. package/server/services/sprint-orchestrator.ts +361 -0
  94. package/server/services/sprint-service.ts +415 -0
  95. package/server/services/workflow-service.ts +461 -0
  96. package/server/utils/cc-hooks-parser.ts +70 -0
  97. package/server/utils/dispatch-utils.ts +395 -0
  98. package/server/utils/logger.ts +109 -0
  99. package/server/utils/terminal-launcher.ts +462 -0
  100. package/server/utils/worktree-manager.ts +104 -0
  101. package/server/watchers/event-watcher.ts +100 -0
  102. package/server/watchers/scope-watcher.ts +38 -0
  103. package/shared/api-types.ts +20 -0
  104. package/shared/default-workflow.json +616 -0
  105. package/shared/workflow-config.ts +170 -0
  106. package/shared/workflow-engine.ts +427 -0
  107. package/src/App.tsx +33 -0
  108. package/src/components/AgentBadge.tsx +40 -0
  109. package/src/components/BatchPreflightModal.tsx +115 -0
  110. package/src/components/CardDisplayToggle.tsx +74 -0
  111. package/src/components/ColumnHeaderActions.tsx +55 -0
  112. package/src/components/ColumnMenu.tsx +99 -0
  113. package/src/components/DeployHistory.tsx +141 -0
  114. package/src/components/DispatchModal.tsx +164 -0
  115. package/src/components/DispatchPopover.tsx +139 -0
  116. package/src/components/DragOverlay.tsx +25 -0
  117. package/src/components/DriftSidebar.tsx +140 -0
  118. package/src/components/EnvironmentStrip.tsx +88 -0
  119. package/src/components/ErrorBoundary.tsx +62 -0
  120. package/src/components/FilterChip.tsx +105 -0
  121. package/src/components/GateIndicator.tsx +33 -0
  122. package/src/components/IdeaDetailModal.tsx +190 -0
  123. package/src/components/IdeaFormDialog.tsx +113 -0
  124. package/src/components/KanbanColumn.tsx +201 -0
  125. package/src/components/MarkdownRenderer.tsx +114 -0
  126. package/src/components/NeonGrid.tsx +128 -0
  127. package/src/components/PromotionQueue.tsx +89 -0
  128. package/src/components/ScopeCard.tsx +234 -0
  129. package/src/components/ScopeDetailModal.tsx +255 -0
  130. package/src/components/ScopeFilterBar.tsx +152 -0
  131. package/src/components/SearchInput.tsx +102 -0
  132. package/src/components/SessionPanel.tsx +335 -0
  133. package/src/components/SprintContainer.tsx +303 -0
  134. package/src/components/SprintDependencyDialog.tsx +78 -0
  135. package/src/components/SprintPreflightModal.tsx +138 -0
  136. package/src/components/StatusBar.tsx +168 -0
  137. package/src/components/SwimCell.tsx +67 -0
  138. package/src/components/SwimLaneRow.tsx +94 -0
  139. package/src/components/SwimlaneBoardView.tsx +108 -0
  140. package/src/components/VersionBadge.tsx +139 -0
  141. package/src/components/ViewModeSelector.tsx +114 -0
  142. package/src/components/config/AgentChip.tsx +53 -0
  143. package/src/components/config/AgentCreateDialog.tsx +321 -0
  144. package/src/components/config/AgentEditor.tsx +175 -0
  145. package/src/components/config/DirectoryTree.tsx +582 -0
  146. package/src/components/config/FileEditor.tsx +550 -0
  147. package/src/components/config/HookChip.tsx +50 -0
  148. package/src/components/config/StageCard.tsx +198 -0
  149. package/src/components/config/TransitionZone.tsx +173 -0
  150. package/src/components/config/UnifiedWorkflowPipeline.tsx +216 -0
  151. package/src/components/config/WorkflowPipeline.tsx +161 -0
  152. package/src/components/source-control/BranchList.tsx +93 -0
  153. package/src/components/source-control/BranchPanel.tsx +105 -0
  154. package/src/components/source-control/CommitLog.tsx +100 -0
  155. package/src/components/source-control/CommitRow.tsx +47 -0
  156. package/src/components/source-control/GitHubPanel.tsx +110 -0
  157. package/src/components/source-control/GitHubSetupGuide.tsx +52 -0
  158. package/src/components/source-control/GitOverviewBar.tsx +101 -0
  159. package/src/components/source-control/PullRequestList.tsx +69 -0
  160. package/src/components/source-control/WorktreeList.tsx +80 -0
  161. package/src/components/ui/badge.tsx +41 -0
  162. package/src/components/ui/button.tsx +55 -0
  163. package/src/components/ui/card.tsx +78 -0
  164. package/src/components/ui/dialog.tsx +94 -0
  165. package/src/components/ui/popover.tsx +33 -0
  166. package/src/components/ui/scroll-area.tsx +54 -0
  167. package/src/components/ui/separator.tsx +28 -0
  168. package/src/components/ui/tabs.tsx +52 -0
  169. package/src/components/ui/toggle-switch.tsx +35 -0
  170. package/src/components/ui/tooltip.tsx +27 -0
  171. package/src/components/workflow/AddEdgeDialog.tsx +217 -0
  172. package/src/components/workflow/AddListDialog.tsx +201 -0
  173. package/src/components/workflow/ChecklistEditor.tsx +239 -0
  174. package/src/components/workflow/CommandPrefixManager.tsx +118 -0
  175. package/src/components/workflow/ConfigSettingsPanel.tsx +189 -0
  176. package/src/components/workflow/DirectionSelector.tsx +133 -0
  177. package/src/components/workflow/DispatchConfigPanel.tsx +180 -0
  178. package/src/components/workflow/EdgeDetailPanel.tsx +236 -0
  179. package/src/components/workflow/EdgePropertyEditor.tsx +251 -0
  180. package/src/components/workflow/EditToolbar.tsx +138 -0
  181. package/src/components/workflow/HookDetailPanel.tsx +250 -0
  182. package/src/components/workflow/HookExecutionLog.tsx +24 -0
  183. package/src/components/workflow/HookSourceModal.tsx +129 -0
  184. package/src/components/workflow/HooksDashboard.tsx +363 -0
  185. package/src/components/workflow/ListPropertyEditor.tsx +251 -0
  186. package/src/components/workflow/MigrationPreviewDialog.tsx +237 -0
  187. package/src/components/workflow/MovementRulesPanel.tsx +188 -0
  188. package/src/components/workflow/NodeDetailPanel.tsx +245 -0
  189. package/src/components/workflow/PresetSelector.tsx +414 -0
  190. package/src/components/workflow/SkillCommandBuilder.tsx +174 -0
  191. package/src/components/workflow/WorkflowEdgeComponent.tsx +145 -0
  192. package/src/components/workflow/WorkflowNode.tsx +147 -0
  193. package/src/components/workflow/graphLayout.ts +186 -0
  194. package/src/components/workflow/mergeHooks.ts +85 -0
  195. package/src/components/workflow/useEditHistory.ts +88 -0
  196. package/src/components/workflow/useWorkflowEditor.ts +262 -0
  197. package/src/components/workflow/validateConfig.ts +70 -0
  198. package/src/hooks/useActiveDispatches.ts +198 -0
  199. package/src/hooks/useBoardSettings.ts +170 -0
  200. package/src/hooks/useCardDisplay.ts +57 -0
  201. package/src/hooks/useCcHooks.ts +24 -0
  202. package/src/hooks/useConfigTree.ts +51 -0
  203. package/src/hooks/useEnforcementRules.ts +46 -0
  204. package/src/hooks/useEvents.ts +59 -0
  205. package/src/hooks/useFileEditor.ts +165 -0
  206. package/src/hooks/useGates.ts +57 -0
  207. package/src/hooks/useIdeaActions.ts +53 -0
  208. package/src/hooks/useKanbanDnd.ts +410 -0
  209. package/src/hooks/useOrbitalConfig.ts +54 -0
  210. package/src/hooks/usePipeline.ts +47 -0
  211. package/src/hooks/usePipelineData.ts +338 -0
  212. package/src/hooks/useReconnect.ts +25 -0
  213. package/src/hooks/useScopeFilters.ts +125 -0
  214. package/src/hooks/useScopeSessions.ts +44 -0
  215. package/src/hooks/useScopes.ts +67 -0
  216. package/src/hooks/useSearch.ts +67 -0
  217. package/src/hooks/useSettings.tsx +187 -0
  218. package/src/hooks/useSocket.ts +25 -0
  219. package/src/hooks/useSourceControl.ts +105 -0
  220. package/src/hooks/useSprintPreflight.ts +55 -0
  221. package/src/hooks/useSprints.ts +154 -0
  222. package/src/hooks/useStatusBarHighlight.ts +18 -0
  223. package/src/hooks/useSwimlaneBoardSettings.ts +104 -0
  224. package/src/hooks/useTheme.ts +9 -0
  225. package/src/hooks/useTransitionReadiness.ts +53 -0
  226. package/src/hooks/useVersion.ts +155 -0
  227. package/src/hooks/useViolations.ts +65 -0
  228. package/src/hooks/useWorkflow.tsx +125 -0
  229. package/src/hooks/useZoomModifier.ts +19 -0
  230. package/src/index.css +797 -0
  231. package/src/layouts/DashboardLayout.tsx +113 -0
  232. package/src/lib/collisionDetection.ts +20 -0
  233. package/src/lib/scope-fields.ts +61 -0
  234. package/src/lib/swimlane.ts +146 -0
  235. package/src/lib/utils.ts +15 -0
  236. package/src/main.tsx +19 -0
  237. package/src/socket.ts +11 -0
  238. package/src/types/index.ts +497 -0
  239. package/src/views/AgentFeed.tsx +339 -0
  240. package/src/views/DeployPipeline.tsx +59 -0
  241. package/src/views/EnforcementView.tsx +378 -0
  242. package/src/views/PrimitivesConfig.tsx +500 -0
  243. package/src/views/QualityGates.tsx +1012 -0
  244. package/src/views/ScopeBoard.tsx +454 -0
  245. package/src/views/SessionTimeline.tsx +516 -0
  246. package/src/views/Settings.tsx +183 -0
  247. package/src/views/SourceControl.tsx +95 -0
  248. package/src/views/WorkflowVisualizer.tsx +382 -0
  249. package/tailwind.config.js +161 -0
  250. package/templates/agents/AUTO-INVOKE.md +180 -0
  251. package/templates/agents/CONFLICT-RESOLUTION.md +128 -0
  252. package/templates/agents/QUICK-REFERENCE.md +122 -0
  253. package/templates/agents/README.md +188 -0
  254. package/templates/agents/SKILL-TRIGGERS.md +100 -0
  255. package/templates/agents/blue-team/frontend-designer.md +424 -0
  256. package/templates/agents/green-team/architect.md +526 -0
  257. package/templates/agents/green-team/rules-enforcer.md +131 -0
  258. package/templates/agents/red-team/attacker-learned.md +24 -0
  259. package/templates/agents/red-team/attacker.md +486 -0
  260. package/templates/agents/red-team/chaos.md +548 -0
  261. package/templates/agents/reference/component-registry.md +82 -0
  262. package/templates/agents/workflows/full-mode.md +218 -0
  263. package/templates/agents/workflows/quick-mode.md +118 -0
  264. package/templates/agents/workflows/security-mode.md +283 -0
  265. package/templates/anti-patterns/dangerous-shortcuts.md +427 -0
  266. package/templates/config/agent-triggers.json +92 -0
  267. package/templates/hooks/agent-team-gate.sh +31 -0
  268. package/templates/hooks/agent-trigger.sh +97 -0
  269. package/templates/hooks/block-push.sh +66 -0
  270. package/templates/hooks/block-workarounds.sh +61 -0
  271. package/templates/hooks/blocker-check.sh +28 -0
  272. package/templates/hooks/completion-checklist.sh +28 -0
  273. package/templates/hooks/decision-capture.sh +15 -0
  274. package/templates/hooks/dependency-check.sh +27 -0
  275. package/templates/hooks/end-session.sh +31 -0
  276. package/templates/hooks/exploration-logger.sh +37 -0
  277. package/templates/hooks/files-changed-summary.sh +37 -0
  278. package/templates/hooks/get-session-id.sh +49 -0
  279. package/templates/hooks/git-commit-guard.sh +34 -0
  280. package/templates/hooks/init-session.sh +93 -0
  281. package/templates/hooks/orbital-emit.sh +79 -0
  282. package/templates/hooks/orbital-report-deploy.sh +78 -0
  283. package/templates/hooks/orbital-report-gates.sh +40 -0
  284. package/templates/hooks/orbital-report-violation.sh +36 -0
  285. package/templates/hooks/orbital-scope-update.sh +53 -0
  286. package/templates/hooks/phase-verify-reminder.sh +26 -0
  287. package/templates/hooks/review-gate-check.sh +82 -0
  288. package/templates/hooks/scope-commit-logger.sh +37 -0
  289. package/templates/hooks/scope-create-cleanup.sh +36 -0
  290. package/templates/hooks/scope-create-gate.sh +80 -0
  291. package/templates/hooks/scope-create-tracker.sh +17 -0
  292. package/templates/hooks/scope-file-sync.sh +53 -0
  293. package/templates/hooks/scope-gate.sh +35 -0
  294. package/templates/hooks/scope-helpers.sh +188 -0
  295. package/templates/hooks/scope-lifecycle-gate.sh +139 -0
  296. package/templates/hooks/scope-prepare.sh +244 -0
  297. package/templates/hooks/scope-transition.sh +172 -0
  298. package/templates/hooks/session-enforcer.sh +143 -0
  299. package/templates/hooks/time-tracker.sh +33 -0
  300. package/templates/lessons-learned.md +15 -0
  301. package/templates/orbital.config.json +35 -0
  302. package/templates/presets/development.json +42 -0
  303. package/templates/presets/gitflow.json +712 -0
  304. package/templates/presets/minimal.json +23 -0
  305. package/templates/quick/rules.md +218 -0
  306. package/templates/scopes/_template.md +255 -0
  307. package/templates/settings-hooks.json +98 -0
  308. package/templates/skills/git-commit/SKILL.md +85 -0
  309. package/templates/skills/git-dev/SKILL.md +99 -0
  310. package/templates/skills/git-hotfix/SKILL.md +223 -0
  311. package/templates/skills/git-main/SKILL.md +84 -0
  312. package/templates/skills/git-production/SKILL.md +165 -0
  313. package/templates/skills/git-staging/SKILL.md +112 -0
  314. package/templates/skills/scope-create/SKILL.md +81 -0
  315. package/templates/skills/scope-fix-review/SKILL.md +168 -0
  316. package/templates/skills/scope-implement/SKILL.md +110 -0
  317. package/templates/skills/scope-post-review/SKILL.md +144 -0
  318. package/templates/skills/scope-pre-review/SKILL.md +211 -0
  319. package/templates/skills/scope-verify/SKILL.md +201 -0
  320. package/templates/skills/session-init/SKILL.md +62 -0
  321. package/templates/skills/session-resume/SKILL.md +201 -0
  322. package/templates/skills/test-checks/SKILL.md +171 -0
  323. package/templates/skills/test-code-review/SKILL.md +252 -0
  324. package/tsconfig.json +25 -0
  325. package/vite.config.ts +38 -0
@@ -0,0 +1,397 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { createLogger } from '../utils/logger.js';
5
+ const log = createLogger('workflow');
6
+ import { isWorkflowConfig } from '../../shared/workflow-config.js';
7
+ /** Short content digest of a WorkflowConfig (ignoring internal metadata fields). */
8
+ function configDigest(config) {
9
+ // Strip internal metadata so the digest only reflects user-visible config
10
+ const { _defaultDigest: _, ...rest } = config;
11
+ return crypto.createHash('sha256').update(JSON.stringify(rest)).digest('hex').slice(0, 16);
12
+ }
13
+ // ─── WorkflowService ───────────────────────────────────
14
+ export class WorkflowService {
15
+ presetsDir;
16
+ activeConfigPath;
17
+ scopesDir;
18
+ engine;
19
+ defaultConfigPath;
20
+ manifestPath;
21
+ io = null;
22
+ constructor(configDir, engine, scopesDir, defaultConfigPath) {
23
+ this.presetsDir = path.join(configDir, 'workflows');
24
+ this.activeConfigPath = path.join(configDir, 'workflow.json');
25
+ this.scopesDir = scopesDir;
26
+ this.engine = engine;
27
+ this.defaultConfigPath = defaultConfigPath;
28
+ this.manifestPath = path.join(configDir, 'workflow-manifest.sh');
29
+ // Ensure directories exist
30
+ if (!fs.existsSync(this.presetsDir))
31
+ fs.mkdirSync(this.presetsDir, { recursive: true });
32
+ // ─── Sync active config with bundled default ─────────────────
33
+ // The active config is a copy of the bundled default-workflow.json.
34
+ // When the package updates (new colors, lists, edges, etc.), the cached
35
+ // workflow.json becomes stale. We embed a _defaultDigest so we can
36
+ // detect drift and auto-refresh — but only if the user hasn't applied
37
+ // a custom preset (which strips the digest).
38
+ const defaultConfig = JSON.parse(fs.readFileSync(this.defaultConfigPath, 'utf-8'));
39
+ const currentDigest = configDigest(defaultConfig);
40
+ if (!fs.existsSync(this.activeConfigPath)) {
41
+ // First run — seed from bundled default with digest marker
42
+ this.writeWithDigest(this.activeConfigPath, defaultConfig, currentDigest);
43
+ this.engine.reload(defaultConfig);
44
+ fs.writeFileSync(this.manifestPath, this.engine.generateShellManifest(), 'utf-8');
45
+ }
46
+ else {
47
+ const active = JSON.parse(fs.readFileSync(this.activeConfigPath, 'utf-8'));
48
+ if (!active._defaultDigest) {
49
+ // Legacy file without digest marker. If content matches current default, stamp it.
50
+ // If different, it's user-customized — leave it alone.
51
+ if (configDigest(active) === currentDigest) {
52
+ this.writeWithDigest(this.activeConfigPath, defaultConfig, currentDigest);
53
+ }
54
+ }
55
+ else if (active._defaultDigest !== currentDigest) {
56
+ // Bundled default changed since last sync — refresh + regenerate manifest
57
+ this.writeWithDigest(this.activeConfigPath, defaultConfig, currentDigest);
58
+ this.engine.reload(defaultConfig);
59
+ fs.writeFileSync(this.manifestPath, this.engine.generateShellManifest(), 'utf-8');
60
+ }
61
+ }
62
+ // Always keep the "default" preset in sync with the bundled default
63
+ const defaultPresetPath = path.join(this.presetsDir, 'default.json');
64
+ const preset = { _preset: { name: 'default', savedAt: new Date().toISOString(), savedFrom: 'bundled' }, ...defaultConfig };
65
+ fs.writeFileSync(defaultPresetPath, JSON.stringify(preset, null, 2));
66
+ }
67
+ setSocketServer(io) {
68
+ this.io = io;
69
+ }
70
+ getEngine() {
71
+ return this.engine;
72
+ }
73
+ // ─── Validation ──────────────────────────────────────
74
+ validate(config) {
75
+ const errors = [];
76
+ const warnings = [];
77
+ if (!isWorkflowConfig(config)) {
78
+ errors.push('Invalid config shape: must have version=1, name, lists[], edges[]');
79
+ return { valid: false, errors, warnings };
80
+ }
81
+ if (config.branchingMode !== undefined && config.branchingMode !== 'trunk' && config.branchingMode !== 'worktree') {
82
+ warnings.push(`Invalid branchingMode: "${config.branchingMode}" — defaulting to "trunk"`);
83
+ }
84
+ // Unique list IDs
85
+ const listIds = new Set();
86
+ for (const list of config.lists) {
87
+ if (listIds.has(list.id))
88
+ errors.push(`Duplicate list ID: "${list.id}"`);
89
+ listIds.add(list.id);
90
+ }
91
+ // Valid edge references + no duplicates
92
+ const edgeKeys = new Set();
93
+ for (const edge of config.edges) {
94
+ if (!listIds.has(edge.from))
95
+ errors.push(`Edge references unknown list: from="${edge.from}"`);
96
+ if (!listIds.has(edge.to))
97
+ errors.push(`Edge references unknown list: to="${edge.to}"`);
98
+ const key = `${edge.from}:${edge.to}`;
99
+ if (edgeKeys.has(key))
100
+ errors.push(`Duplicate edge: ${key}`);
101
+ edgeKeys.add(key);
102
+ }
103
+ // Exactly 1 entry point
104
+ const entryPoints = config.lists.filter((l) => l.isEntryPoint);
105
+ if (entryPoints.length === 0)
106
+ errors.push('No entry point defined (isEntryPoint=true)');
107
+ if (entryPoints.length > 1)
108
+ errors.push(`Multiple entry points: ${entryPoints.map((l) => l.id).join(', ')}`);
109
+ // Graph connectivity — all non-terminal lists reachable from entry point via edges
110
+ if (entryPoints.length === 1 && errors.length === 0) {
111
+ const terminal = new Set(config.terminalStatuses ?? []);
112
+ const reachable = new Set();
113
+ const queue = [entryPoints[0].id];
114
+ while (queue.length > 0) {
115
+ const current = queue.shift();
116
+ if (reachable.has(current))
117
+ continue;
118
+ reachable.add(current);
119
+ for (const edge of config.edges) {
120
+ if (edge.from === current && !reachable.has(edge.to))
121
+ queue.push(edge.to);
122
+ }
123
+ }
124
+ for (const list of config.lists) {
125
+ if (!terminal.has(list.id) && !reachable.has(list.id)) {
126
+ errors.push(`List "${list.id}" is not reachable from entry point`);
127
+ }
128
+ }
129
+ }
130
+ return { valid: errors.length === 0, errors, warnings };
131
+ }
132
+ // ─── Active Config ──────────────────────────────────
133
+ getActive() {
134
+ let raw;
135
+ if (fs.existsSync(this.activeConfigPath)) {
136
+ raw = JSON.parse(fs.readFileSync(this.activeConfigPath, 'utf-8'));
137
+ }
138
+ else {
139
+ raw = JSON.parse(fs.readFileSync(this.defaultConfigPath, 'utf-8'));
140
+ }
141
+ // Strip internal digest marker before returning to clients
142
+ delete raw._defaultDigest;
143
+ return raw;
144
+ }
145
+ updateActive(config) {
146
+ const result = this.validate(config);
147
+ if (!result.valid)
148
+ return result;
149
+ // Strip digest — user edits mean this is no longer a pristine default
150
+ delete config._defaultDigest;
151
+ this.writeAtomic(this.activeConfigPath, config);
152
+ log.info('Workflow config updated');
153
+ this.io?.emit('workflow:changed', { config });
154
+ return result;
155
+ }
156
+ // ─── Preset Management ──────────────────────────────
157
+ listPresets() {
158
+ const files = fs.readdirSync(this.presetsDir).filter((f) => f.endsWith('.json'));
159
+ return files.map((f) => {
160
+ const filePath = path.join(this.presetsDir, f);
161
+ const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
162
+ const stat = fs.statSync(filePath);
163
+ return {
164
+ name: f.endsWith('.json') ? f.slice(0, -5) : f,
165
+ createdAt: content._preset?.savedAt ?? stat.birthtime.toISOString(),
166
+ listCount: Array.isArray(content.lists) ? content.lists.length : 0,
167
+ edgeCount: Array.isArray(content.edges) ? content.edges.length : 0,
168
+ };
169
+ });
170
+ }
171
+ savePreset(name) {
172
+ if (!/^[a-zA-Z0-9-]+$/.test(name) || name.length > 50) {
173
+ throw new Error('Preset name must be alphanumeric with hyphens, max 50 characters');
174
+ }
175
+ if (name === 'default') {
176
+ throw new Error('Cannot overwrite the "default" preset');
177
+ }
178
+ const config = this.getActive();
179
+ const preset = { _preset: { name, savedAt: new Date().toISOString(), savedFrom: 'active' }, ...config };
180
+ fs.writeFileSync(path.join(this.presetsDir, `${name}.json`), JSON.stringify(preset, null, 2));
181
+ log.info('Preset saved', { name });
182
+ }
183
+ getPreset(name) {
184
+ const filePath = path.join(this.presetsDir, `${name}.json`);
185
+ if (!fs.existsSync(filePath))
186
+ throw new Error(`Preset "${name}" not found`);
187
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
188
+ delete raw._preset;
189
+ return raw;
190
+ }
191
+ deletePreset(name) {
192
+ if (name === 'default')
193
+ throw new Error('Cannot delete the "default" preset');
194
+ const filePath = path.join(this.presetsDir, `${name}.json`);
195
+ if (!fs.existsSync(filePath))
196
+ throw new Error(`Preset "${name}" not found`);
197
+ fs.unlinkSync(filePath);
198
+ log.info('Preset deleted', { name });
199
+ }
200
+ // ─── Migration Engine ───────────────────────────────
201
+ previewMigration(newConfig) {
202
+ const validation = this.validate(newConfig);
203
+ if (!validation.valid) {
204
+ return {
205
+ valid: false, validationErrors: validation.errors,
206
+ removedLists: [], addedLists: [], dirsToCreate: [], dirsToRemove: [],
207
+ orphanedScopes: [], lostEdges: [], suggestedMappings: {},
208
+ impactSummary: 'New config has validation errors',
209
+ };
210
+ }
211
+ const activeConfig = this.getActive();
212
+ const activeIds = new Set(activeConfig.lists.map((l) => l.id));
213
+ const newIds = new Set(newConfig.lists.map((l) => l.id));
214
+ const removedLists = [...activeIds].filter((id) => !newIds.has(id));
215
+ const addedLists = [...newIds].filter((id) => !activeIds.has(id));
216
+ const orphanedScopes = removedLists
217
+ .map((listId) => ({ listId, scopeFiles: this.scanScopesInList(listId) }))
218
+ .filter((o) => o.scopeFiles.length > 0);
219
+ const lostEdges = activeConfig.edges
220
+ .filter((e) => !newIds.has(e.from) || !newIds.has(e.to))
221
+ .map((e) => ({ from: e.from, to: e.to }));
222
+ const suggestedMappings = {};
223
+ for (const orphan of orphanedScopes) {
224
+ suggestedMappings[orphan.listId] = this.findClosestList(orphan.listId, activeConfig, newConfig);
225
+ }
226
+ // Directories to create: new lists with hasDirectory that don't exist on disk
227
+ const dirsToCreate = newConfig.lists
228
+ .filter((l) => l.hasDirectory && !fs.existsSync(path.join(this.scopesDir, l.id)))
229
+ .map((l) => l.id);
230
+ // Directories to remove: removed lists whose scopes/ dir is empty (or will be after moves)
231
+ const dirsToRemove = removedLists.filter((id) => {
232
+ const dir = path.join(this.scopesDir, id);
233
+ if (!fs.existsSync(dir))
234
+ return false;
235
+ const remaining = fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
236
+ const orphan = orphanedScopes.find((o) => o.listId === id);
237
+ // All .md files will be moved out, so the dir will be empty
238
+ return orphan ? remaining.length <= orphan.scopeFiles.length : remaining.length === 0;
239
+ });
240
+ const parts = [];
241
+ if (removedLists.length)
242
+ parts.push(`${removedLists.length} list(s) removed`);
243
+ if (addedLists.length)
244
+ parts.push(`${addedLists.length} list(s) added`);
245
+ if (dirsToCreate.length)
246
+ parts.push(`${dirsToCreate.length} scope dir(s) to create`);
247
+ if (dirsToRemove.length)
248
+ parts.push(`${dirsToRemove.length} scope dir(s) to remove`);
249
+ if (orphanedScopes.length) {
250
+ const total = orphanedScopes.reduce((sum, o) => sum + o.scopeFiles.length, 0);
251
+ parts.push(`${total} scope(s) in ${orphanedScopes.length} orphaned list(s) need migration`);
252
+ }
253
+ if (lostEdges.length)
254
+ parts.push(`${lostEdges.length} edge(s) lost`);
255
+ return {
256
+ valid: true, validationErrors: [],
257
+ removedLists, addedLists, dirsToCreate, dirsToRemove,
258
+ orphanedScopes, lostEdges, suggestedMappings,
259
+ impactSummary: parts.length > 0 ? parts.join('; ') : 'No impact — configs are compatible',
260
+ };
261
+ }
262
+ // ─── Atomic Apply ───────────────────────────────────
263
+ applyMigration(newConfig, orphanMappings) {
264
+ // User-initiated migration — strip digest so auto-refresh won't overwrite
265
+ delete newConfig._defaultDigest;
266
+ // Step 1: Validate
267
+ const validation = this.validate(newConfig);
268
+ if (!validation.valid) {
269
+ throw new Error(`Validation failed: ${validation.errors.join('; ')}`);
270
+ }
271
+ // Step 2: Compute impact + verify all orphans have valid mappings
272
+ const plan = this.previewMigration(newConfig);
273
+ const newIds = new Set(newConfig.lists.map((l) => l.id));
274
+ for (const orphan of plan.orphanedScopes) {
275
+ const target = orphanMappings[orphan.listId];
276
+ if (!target)
277
+ throw new Error(`Missing orphan mapping for list "${orphan.listId}"`);
278
+ if (!newIds.has(target))
279
+ throw new Error(`Orphan mapping target "${target}" is not a valid list in the new config`);
280
+ }
281
+ // Backup current config for rollback
282
+ const backupPath = this.activeConfigPath + '.backup';
283
+ if (fs.existsSync(this.activeConfigPath))
284
+ fs.copyFileSync(this.activeConfigPath, backupPath);
285
+ const moves = [];
286
+ const migratedScopes = [];
287
+ try {
288
+ // Step 3: Move scope files + update frontmatter
289
+ for (const orphan of plan.orphanedScopes) {
290
+ const targetId = orphanMappings[orphan.listId];
291
+ const targetDir = path.join(this.scopesDir, targetId);
292
+ if (!fs.existsSync(targetDir))
293
+ fs.mkdirSync(targetDir, { recursive: true });
294
+ for (const file of orphan.scopeFiles) {
295
+ const srcPath = path.join(this.scopesDir, orphan.listId, file);
296
+ const originalContent = fs.readFileSync(srcPath, 'utf-8');
297
+ const destPath = path.join(targetDir, file);
298
+ fs.renameSync(srcPath, destPath);
299
+ moves.push({ src: srcPath, dest: destPath, originalContent });
300
+ this.updateFrontmatterStatus(destPath, targetId);
301
+ migratedScopes.push({ file, from: orphan.listId, to: targetId });
302
+ }
303
+ }
304
+ // Step 4: Create scopes/ directories for added lists with hasDirectory
305
+ for (const list of newConfig.lists) {
306
+ if (list.hasDirectory) {
307
+ const dir = path.join(this.scopesDir, list.id);
308
+ if (!fs.existsSync(dir))
309
+ fs.mkdirSync(dir, { recursive: true });
310
+ }
311
+ }
312
+ // Step 5: Remove empty scopes/ directories for removed lists
313
+ for (const listId of plan.removedLists) {
314
+ const dir = path.join(this.scopesDir, listId);
315
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) {
316
+ fs.rmdirSync(dir);
317
+ }
318
+ }
319
+ // Step 6: Apply config atomically + regenerate manifest + reload engine
320
+ this.writeAtomic(this.activeConfigPath, newConfig);
321
+ this.engine.reload(newConfig);
322
+ const manifest = this.engine.generateShellManifest();
323
+ const tmpManifestPath = this.manifestPath + '.tmp';
324
+ fs.writeFileSync(tmpManifestPath, manifest);
325
+ fs.renameSync(tmpManifestPath, this.manifestPath);
326
+ // Step 7: Emit socket event + log
327
+ this.io?.emit('workflow:changed', { config: newConfig, migratedScopes });
328
+ log.info('Workflow migrated', { scopesMoved: migratedScopes.length, removedLists: plan.removedLists.length });
329
+ // Clean up backup on success
330
+ if (fs.existsSync(backupPath))
331
+ fs.unlinkSync(backupPath);
332
+ }
333
+ catch (err) {
334
+ // Rollback: reverse scope file moves with original content
335
+ for (const move of moves.reverse()) {
336
+ try {
337
+ fs.renameSync(move.dest, move.src);
338
+ fs.writeFileSync(move.src, move.originalContent);
339
+ }
340
+ catch (rollbackErr) {
341
+ log.error('Migration rollback failed', { file: move.src, error: String(rollbackErr) });
342
+ }
343
+ }
344
+ // Rollback: restore original config + reload engine
345
+ if (fs.existsSync(backupPath)) {
346
+ fs.copyFileSync(backupPath, this.activeConfigPath);
347
+ fs.unlinkSync(backupPath);
348
+ const original = JSON.parse(fs.readFileSync(this.activeConfigPath, 'utf-8'));
349
+ this.engine.reload(original);
350
+ }
351
+ throw err;
352
+ }
353
+ return plan;
354
+ }
355
+ // ─── Helpers ────────────────────────────────────────
356
+ scanScopesInList(listId) {
357
+ const dir = path.join(this.scopesDir, listId);
358
+ if (!fs.existsSync(dir))
359
+ return [];
360
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
361
+ }
362
+ findClosestList(removedId, activeConfig, newConfig) {
363
+ const removed = activeConfig.lists.find((l) => l.id === removedId);
364
+ const entryId = newConfig.lists.find((l) => l.isEntryPoint)?.id ?? newConfig.lists[0].id;
365
+ if (!removed)
366
+ return entryId;
367
+ // 1. Same group as removed list
368
+ if (removed.group) {
369
+ const match = newConfig.lists.find((l) => l.group === removed.group);
370
+ if (match)
371
+ return match.id;
372
+ }
373
+ // 2. Closest list by order number
374
+ const sorted = [...newConfig.lists].sort((a, b) => Math.abs(a.order - removed.order) - Math.abs(b.order - removed.order));
375
+ if (sorted.length > 0)
376
+ return sorted[0].id;
377
+ // 3. Entry point as last resort
378
+ return entryId;
379
+ }
380
+ updateFrontmatterStatus(filePath, newStatus) {
381
+ const content = fs.readFileSync(filePath, 'utf-8');
382
+ const updated = content.replace(/^(status:\s*).+$/m, `$1${newStatus}`);
383
+ fs.writeFileSync(filePath, updated);
384
+ }
385
+ writeAtomic(targetPath, data) {
386
+ const tmpPath = targetPath + '.tmp';
387
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
388
+ fs.renameSync(tmpPath, targetPath);
389
+ }
390
+ /** Write config with a _defaultDigest marker so we can detect when the bundled default changes. */
391
+ writeWithDigest(targetPath, config, digest) {
392
+ const withDigest = { _defaultDigest: digest, ...config };
393
+ const tmpPath = targetPath + '.tmp';
394
+ fs.writeFileSync(tmpPath, JSON.stringify(withDigest, null, 2));
395
+ fs.renameSync(tmpPath, targetPath);
396
+ }
397
+ }
@@ -0,0 +1,49 @@
1
+ import { readFileSync } from 'node:fs';
2
+ const CC_HOOK_EVENTS = ['SessionStart', 'SessionEnd', 'PreToolUse', 'PostToolUse'];
3
+ function extractScriptPath(command) {
4
+ // Strip "$CLAUDE_PROJECT_DIR"/ prefix and quotes
5
+ return command
6
+ .replace(/^"?\$CLAUDE_PROJECT_DIR"?\/?/, '')
7
+ .replace(/^["']|["']$/g, '');
8
+ }
9
+ function deriveId(scriptName) {
10
+ // "init-session.sh" → "init-session"
11
+ // Uses the bare filename so it matches workflow hook IDs when they exist.
12
+ return scriptName.replace(/\.[^.]+$/, '');
13
+ }
14
+ export function parseCcHooks(settingsPath) {
15
+ let raw;
16
+ try {
17
+ raw = readFileSync(settingsPath, 'utf-8');
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ const settings = JSON.parse(raw);
23
+ if (!settings.hooks)
24
+ return [];
25
+ const results = [];
26
+ for (const event of CC_HOOK_EVENTS) {
27
+ const groups = settings.hooks[event];
28
+ if (!Array.isArray(groups))
29
+ continue;
30
+ for (const group of groups) {
31
+ const matcher = group.matcher ?? null;
32
+ for (const entry of group.hooks) {
33
+ if (entry.type !== 'command')
34
+ continue;
35
+ const scriptPath = extractScriptPath(entry.command);
36
+ const scriptName = scriptPath.split('/').pop() ?? scriptPath;
37
+ results.push({
38
+ id: deriveId(scriptName),
39
+ scriptPath,
40
+ scriptName,
41
+ event,
42
+ matcher,
43
+ statusMessage: entry.statusMessage ?? '',
44
+ });
45
+ }
46
+ }
47
+ }
48
+ return results;
49
+ }