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,461 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import type { Server } from 'socket.io';
5
+ import type { WorkflowConfig } from '../../shared/workflow-config.js';
6
+ import { createLogger } from '../utils/logger.js';
7
+
8
+ const log = createLogger('workflow');
9
+ import { isWorkflowConfig } from '../../shared/workflow-config.js';
10
+ import type { WorkflowEngine } from '../../shared/workflow-engine.js';
11
+
12
+ /** Short content digest of a WorkflowConfig (ignoring internal metadata fields). */
13
+ function configDigest(config: WorkflowConfig): string {
14
+ // Strip internal metadata so the digest only reflects user-visible config
15
+ const { _defaultDigest: _, ...rest } = config as WorkflowConfig & { _defaultDigest?: string };
16
+ return crypto.createHash('sha256').update(JSON.stringify(rest)).digest('hex').slice(0, 16);
17
+ }
18
+
19
+ // ─── Types ──────────────────────────────────────────────
20
+
21
+ export interface ValidationResult {
22
+ valid: boolean;
23
+ errors: string[];
24
+ warnings: string[];
25
+ }
26
+
27
+ export interface PresetInfo {
28
+ name: string;
29
+ createdAt: string;
30
+ listCount: number;
31
+ edgeCount: number;
32
+ }
33
+
34
+ export interface MigrationPlan {
35
+ valid: boolean;
36
+ validationErrors: string[];
37
+ removedLists: string[];
38
+ addedLists: string[];
39
+ dirsToCreate: string[];
40
+ dirsToRemove: string[];
41
+ orphanedScopes: Array<{ listId: string; scopeFiles: string[] }>;
42
+ lostEdges: Array<{ from: string; to: string }>;
43
+ suggestedMappings: Record<string, string>;
44
+ impactSummary: string;
45
+ }
46
+
47
+ // ─── WorkflowService ───────────────────────────────────
48
+
49
+ export class WorkflowService {
50
+ private presetsDir: string;
51
+ private activeConfigPath: string;
52
+ private scopesDir: string;
53
+ private engine: WorkflowEngine;
54
+ private defaultConfigPath: string;
55
+ private manifestPath: string;
56
+ private io: Server | null = null;
57
+
58
+ constructor(configDir: string, engine: WorkflowEngine, scopesDir: string, defaultConfigPath: string) {
59
+ this.presetsDir = path.join(configDir, 'workflows');
60
+ this.activeConfigPath = path.join(configDir, 'workflow.json');
61
+ this.scopesDir = scopesDir;
62
+ this.engine = engine;
63
+ this.defaultConfigPath = defaultConfigPath;
64
+ this.manifestPath = path.join(configDir, 'workflow-manifest.sh');
65
+
66
+ // Ensure directories exist
67
+ if (!fs.existsSync(this.presetsDir)) fs.mkdirSync(this.presetsDir, { recursive: true });
68
+
69
+ // ─── Sync active config with bundled default ─────────────────
70
+ // The active config is a copy of the bundled default-workflow.json.
71
+ // When the package updates (new colors, lists, edges, etc.), the cached
72
+ // workflow.json becomes stale. We embed a _defaultDigest so we can
73
+ // detect drift and auto-refresh — but only if the user hasn't applied
74
+ // a custom preset (which strips the digest).
75
+ const defaultConfig = JSON.parse(fs.readFileSync(this.defaultConfigPath, 'utf-8')) as WorkflowConfig;
76
+ const currentDigest = configDigest(defaultConfig);
77
+
78
+ if (!fs.existsSync(this.activeConfigPath)) {
79
+ // First run — seed from bundled default with digest marker
80
+ this.writeWithDigest(this.activeConfigPath, defaultConfig, currentDigest);
81
+ this.engine.reload(defaultConfig);
82
+ fs.writeFileSync(this.manifestPath, this.engine.generateShellManifest(), 'utf-8');
83
+ } else {
84
+ const active = JSON.parse(fs.readFileSync(this.activeConfigPath, 'utf-8')) as WorkflowConfig & { _defaultDigest?: string };
85
+ if (!active._defaultDigest) {
86
+ // Legacy file without digest marker. If content matches current default, stamp it.
87
+ // If different, it's user-customized — leave it alone.
88
+ if (configDigest(active) === currentDigest) {
89
+ this.writeWithDigest(this.activeConfigPath, defaultConfig, currentDigest);
90
+ }
91
+ } else if (active._defaultDigest !== currentDigest) {
92
+ // Bundled default changed since last sync — refresh + regenerate manifest
93
+ this.writeWithDigest(this.activeConfigPath, defaultConfig, currentDigest);
94
+ this.engine.reload(defaultConfig);
95
+ fs.writeFileSync(this.manifestPath, this.engine.generateShellManifest(), 'utf-8');
96
+ }
97
+ }
98
+
99
+ // Always keep the "default" preset in sync with the bundled default
100
+ const defaultPresetPath = path.join(this.presetsDir, 'default.json');
101
+ const preset = { _preset: { name: 'default', savedAt: new Date().toISOString(), savedFrom: 'bundled' }, ...defaultConfig };
102
+ fs.writeFileSync(defaultPresetPath, JSON.stringify(preset, null, 2));
103
+ }
104
+
105
+ setSocketServer(io: Server): void {
106
+ this.io = io;
107
+ }
108
+
109
+ getEngine(): WorkflowEngine {
110
+ return this.engine;
111
+ }
112
+
113
+ // ─── Validation ──────────────────────────────────────
114
+
115
+ validate(config: WorkflowConfig): ValidationResult {
116
+ const errors: string[] = [];
117
+ const warnings: string[] = [];
118
+
119
+ if (!isWorkflowConfig(config)) {
120
+ errors.push('Invalid config shape: must have version=1, name, lists[], edges[]');
121
+ return { valid: false, errors, warnings };
122
+ }
123
+
124
+ if (config.branchingMode !== undefined && config.branchingMode !== 'trunk' && config.branchingMode !== 'worktree') {
125
+ warnings.push(`Invalid branchingMode: "${config.branchingMode}" — defaulting to "trunk"`);
126
+ }
127
+
128
+ // Unique list IDs
129
+ const listIds = new Set<string>();
130
+ for (const list of config.lists) {
131
+ if (listIds.has(list.id)) errors.push(`Duplicate list ID: "${list.id}"`);
132
+ listIds.add(list.id);
133
+ }
134
+
135
+ // Valid edge references + no duplicates
136
+ const edgeKeys = new Set<string>();
137
+ for (const edge of config.edges) {
138
+ if (!listIds.has(edge.from)) errors.push(`Edge references unknown list: from="${edge.from}"`);
139
+ if (!listIds.has(edge.to)) errors.push(`Edge references unknown list: to="${edge.to}"`);
140
+ const key = `${edge.from}:${edge.to}`;
141
+ if (edgeKeys.has(key)) errors.push(`Duplicate edge: ${key}`);
142
+ edgeKeys.add(key);
143
+ }
144
+
145
+ // Exactly 1 entry point
146
+ const entryPoints = config.lists.filter((l) => l.isEntryPoint);
147
+ if (entryPoints.length === 0) errors.push('No entry point defined (isEntryPoint=true)');
148
+ if (entryPoints.length > 1) errors.push(`Multiple entry points: ${entryPoints.map((l) => l.id).join(', ')}`);
149
+
150
+ // Graph connectivity — all non-terminal lists reachable from entry point via edges
151
+ if (entryPoints.length === 1 && errors.length === 0) {
152
+ const terminal = new Set(config.terminalStatuses ?? []);
153
+ const reachable = new Set<string>();
154
+ const queue = [entryPoints[0].id];
155
+ while (queue.length > 0) {
156
+ const current = queue.shift()!;
157
+ if (reachable.has(current)) continue;
158
+ reachable.add(current);
159
+ for (const edge of config.edges) {
160
+ if (edge.from === current && !reachable.has(edge.to)) queue.push(edge.to);
161
+ }
162
+ }
163
+ for (const list of config.lists) {
164
+ if (!terminal.has(list.id) && !reachable.has(list.id)) {
165
+ errors.push(`List "${list.id}" is not reachable from entry point`);
166
+ }
167
+ }
168
+ }
169
+
170
+ return { valid: errors.length === 0, errors, warnings };
171
+ }
172
+
173
+ // ─── Active Config ──────────────────────────────────
174
+
175
+ getActive(): WorkflowConfig {
176
+ let raw: WorkflowConfig;
177
+ if (fs.existsSync(this.activeConfigPath)) {
178
+ raw = JSON.parse(fs.readFileSync(this.activeConfigPath, 'utf-8')) as WorkflowConfig;
179
+ } else {
180
+ raw = JSON.parse(fs.readFileSync(this.defaultConfigPath, 'utf-8')) as WorkflowConfig;
181
+ }
182
+ // Strip internal digest marker before returning to clients
183
+ delete (raw as WorkflowConfig & { _defaultDigest?: string })._defaultDigest;
184
+ return raw;
185
+ }
186
+
187
+ updateActive(config: WorkflowConfig): ValidationResult {
188
+ const result = this.validate(config);
189
+ if (!result.valid) return result;
190
+ // Strip digest — user edits mean this is no longer a pristine default
191
+ delete (config as WorkflowConfig & { _defaultDigest?: string })._defaultDigest;
192
+ this.writeAtomic(this.activeConfigPath, config);
193
+ log.info('Workflow config updated');
194
+ this.io?.emit('workflow:changed', { config });
195
+ return result;
196
+ }
197
+
198
+ // ─── Preset Management ──────────────────────────────
199
+
200
+ listPresets(): PresetInfo[] {
201
+ const files = fs.readdirSync(this.presetsDir).filter((f) => f.endsWith('.json'));
202
+ return files.map((f) => {
203
+ const filePath = path.join(this.presetsDir, f);
204
+ const content = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as {
205
+ _preset?: { savedAt?: string };
206
+ lists?: unknown[];
207
+ edges?: unknown[];
208
+ };
209
+ const stat = fs.statSync(filePath);
210
+ return {
211
+ name: f.endsWith('.json') ? f.slice(0, -5) : f,
212
+ createdAt: content._preset?.savedAt ?? stat.birthtime.toISOString(),
213
+ listCount: Array.isArray(content.lists) ? content.lists.length : 0,
214
+ edgeCount: Array.isArray(content.edges) ? content.edges.length : 0,
215
+ };
216
+ });
217
+ }
218
+
219
+ savePreset(name: string): void {
220
+ if (!/^[a-zA-Z0-9-]+$/.test(name) || name.length > 50) {
221
+ throw new Error('Preset name must be alphanumeric with hyphens, max 50 characters');
222
+ }
223
+ if (name === 'default') {
224
+ throw new Error('Cannot overwrite the "default" preset');
225
+ }
226
+ const config = this.getActive();
227
+ const preset = { _preset: { name, savedAt: new Date().toISOString(), savedFrom: 'active' }, ...config };
228
+ fs.writeFileSync(path.join(this.presetsDir, `${name}.json`), JSON.stringify(preset, null, 2));
229
+ log.info('Preset saved', { name });
230
+ }
231
+
232
+ getPreset(name: string): WorkflowConfig {
233
+ const filePath = path.join(this.presetsDir, `${name}.json`);
234
+ if (!fs.existsSync(filePath)) throw new Error(`Preset "${name}" not found`);
235
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as WorkflowConfig & { _preset?: unknown };
236
+ delete raw._preset;
237
+ return raw;
238
+ }
239
+
240
+ deletePreset(name: string): void {
241
+ if (name === 'default') throw new Error('Cannot delete the "default" preset');
242
+ const filePath = path.join(this.presetsDir, `${name}.json`);
243
+ if (!fs.existsSync(filePath)) throw new Error(`Preset "${name}" not found`);
244
+ fs.unlinkSync(filePath);
245
+ log.info('Preset deleted', { name });
246
+ }
247
+
248
+ // ─── Migration Engine ───────────────────────────────
249
+
250
+ previewMigration(newConfig: WorkflowConfig): MigrationPlan {
251
+ const validation = this.validate(newConfig);
252
+ if (!validation.valid) {
253
+ return {
254
+ valid: false, validationErrors: validation.errors,
255
+ removedLists: [], addedLists: [], dirsToCreate: [], dirsToRemove: [],
256
+ orphanedScopes: [], lostEdges: [], suggestedMappings: {},
257
+ impactSummary: 'New config has validation errors',
258
+ };
259
+ }
260
+
261
+ const activeConfig = this.getActive();
262
+ const activeIds = new Set(activeConfig.lists.map((l) => l.id));
263
+ const newIds = new Set(newConfig.lists.map((l) => l.id));
264
+
265
+ const removedLists = [...activeIds].filter((id) => !newIds.has(id));
266
+ const addedLists = [...newIds].filter((id) => !activeIds.has(id));
267
+
268
+ const orphanedScopes = removedLists
269
+ .map((listId) => ({ listId, scopeFiles: this.scanScopesInList(listId) }))
270
+ .filter((o) => o.scopeFiles.length > 0);
271
+
272
+ const lostEdges = activeConfig.edges
273
+ .filter((e) => !newIds.has(e.from) || !newIds.has(e.to))
274
+ .map((e) => ({ from: e.from, to: e.to }));
275
+
276
+ const suggestedMappings: Record<string, string> = {};
277
+ for (const orphan of orphanedScopes) {
278
+ suggestedMappings[orphan.listId] = this.findClosestList(orphan.listId, activeConfig, newConfig);
279
+ }
280
+
281
+ // Directories to create: new lists with hasDirectory that don't exist on disk
282
+ const dirsToCreate = newConfig.lists
283
+ .filter((l) => l.hasDirectory && !fs.existsSync(path.join(this.scopesDir, l.id)))
284
+ .map((l) => l.id);
285
+
286
+ // Directories to remove: removed lists whose scopes/ dir is empty (or will be after moves)
287
+ const dirsToRemove = removedLists.filter((id) => {
288
+ const dir = path.join(this.scopesDir, id);
289
+ if (!fs.existsSync(dir)) return false;
290
+ const remaining = fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
291
+ const orphan = orphanedScopes.find((o) => o.listId === id);
292
+ // All .md files will be moved out, so the dir will be empty
293
+ return orphan ? remaining.length <= orphan.scopeFiles.length : remaining.length === 0;
294
+ });
295
+
296
+ const parts: string[] = [];
297
+ if (removedLists.length) parts.push(`${removedLists.length} list(s) removed`);
298
+ if (addedLists.length) parts.push(`${addedLists.length} list(s) added`);
299
+ if (dirsToCreate.length) parts.push(`${dirsToCreate.length} scope dir(s) to create`);
300
+ if (dirsToRemove.length) parts.push(`${dirsToRemove.length} scope dir(s) to remove`);
301
+ if (orphanedScopes.length) {
302
+ const total = orphanedScopes.reduce((sum, o) => sum + o.scopeFiles.length, 0);
303
+ parts.push(`${total} scope(s) in ${orphanedScopes.length} orphaned list(s) need migration`);
304
+ }
305
+ if (lostEdges.length) parts.push(`${lostEdges.length} edge(s) lost`);
306
+
307
+ return {
308
+ valid: true, validationErrors: [],
309
+ removedLists, addedLists, dirsToCreate, dirsToRemove,
310
+ orphanedScopes, lostEdges, suggestedMappings,
311
+ impactSummary: parts.length > 0 ? parts.join('; ') : 'No impact — configs are compatible',
312
+ };
313
+ }
314
+
315
+ // ─── Atomic Apply ───────────────────────────────────
316
+
317
+ applyMigration(newConfig: WorkflowConfig, orphanMappings: Record<string, string>): MigrationPlan {
318
+ // User-initiated migration — strip digest so auto-refresh won't overwrite
319
+ delete (newConfig as WorkflowConfig & { _defaultDigest?: string })._defaultDigest;
320
+
321
+ // Step 1: Validate
322
+ const validation = this.validate(newConfig);
323
+ if (!validation.valid) {
324
+ throw new Error(`Validation failed: ${validation.errors.join('; ')}`);
325
+ }
326
+
327
+ // Step 2: Compute impact + verify all orphans have valid mappings
328
+ const plan = this.previewMigration(newConfig);
329
+ const newIds = new Set(newConfig.lists.map((l) => l.id));
330
+
331
+ for (const orphan of plan.orphanedScopes) {
332
+ const target = orphanMappings[orphan.listId];
333
+ if (!target) throw new Error(`Missing orphan mapping for list "${orphan.listId}"`);
334
+ if (!newIds.has(target)) throw new Error(`Orphan mapping target "${target}" is not a valid list in the new config`);
335
+ }
336
+
337
+ // Backup current config for rollback
338
+ const backupPath = this.activeConfigPath + '.backup';
339
+ if (fs.existsSync(this.activeConfigPath)) fs.copyFileSync(this.activeConfigPath, backupPath);
340
+
341
+ const moves: Array<{ src: string; dest: string; originalContent: string }> = [];
342
+ const migratedScopes: Array<{ file: string; from: string; to: string }> = [];
343
+
344
+ try {
345
+ // Step 3: Move scope files + update frontmatter
346
+ for (const orphan of plan.orphanedScopes) {
347
+ const targetId = orphanMappings[orphan.listId];
348
+ const targetDir = path.join(this.scopesDir, targetId);
349
+ if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
350
+
351
+ for (const file of orphan.scopeFiles) {
352
+ const srcPath = path.join(this.scopesDir, orphan.listId, file);
353
+ const originalContent = fs.readFileSync(srcPath, 'utf-8');
354
+ const destPath = path.join(targetDir, file);
355
+ fs.renameSync(srcPath, destPath);
356
+ moves.push({ src: srcPath, dest: destPath, originalContent });
357
+ this.updateFrontmatterStatus(destPath, targetId);
358
+ migratedScopes.push({ file, from: orphan.listId, to: targetId });
359
+ }
360
+ }
361
+
362
+ // Step 4: Create scopes/ directories for added lists with hasDirectory
363
+ for (const list of newConfig.lists) {
364
+ if (list.hasDirectory) {
365
+ const dir = path.join(this.scopesDir, list.id);
366
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
367
+ }
368
+ }
369
+
370
+ // Step 5: Remove empty scopes/ directories for removed lists
371
+ for (const listId of plan.removedLists) {
372
+ const dir = path.join(this.scopesDir, listId);
373
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) {
374
+ fs.rmdirSync(dir);
375
+ }
376
+ }
377
+
378
+ // Step 6: Apply config atomically + regenerate manifest + reload engine
379
+ this.writeAtomic(this.activeConfigPath, newConfig);
380
+ this.engine.reload(newConfig);
381
+ const manifest = this.engine.generateShellManifest();
382
+ const tmpManifestPath = this.manifestPath + '.tmp';
383
+ fs.writeFileSync(tmpManifestPath, manifest);
384
+ fs.renameSync(tmpManifestPath, this.manifestPath);
385
+
386
+ // Step 7: Emit socket event + log
387
+ this.io?.emit('workflow:changed', { config: newConfig, migratedScopes });
388
+ log.info('Workflow migrated', { scopesMoved: migratedScopes.length, removedLists: plan.removedLists.length });
389
+
390
+ // Clean up backup on success
391
+ if (fs.existsSync(backupPath)) fs.unlinkSync(backupPath);
392
+ } catch (err) {
393
+ // Rollback: reverse scope file moves with original content
394
+ for (const move of moves.reverse()) {
395
+ try {
396
+ fs.renameSync(move.dest, move.src);
397
+ fs.writeFileSync(move.src, move.originalContent);
398
+ } catch (rollbackErr) { log.error('Migration rollback failed', { file: move.src, error: String(rollbackErr) }); }
399
+ }
400
+ // Rollback: restore original config + reload engine
401
+ if (fs.existsSync(backupPath)) {
402
+ fs.copyFileSync(backupPath, this.activeConfigPath);
403
+ fs.unlinkSync(backupPath);
404
+ const original = JSON.parse(fs.readFileSync(this.activeConfigPath, 'utf-8')) as WorkflowConfig;
405
+ this.engine.reload(original);
406
+ }
407
+ throw err;
408
+ }
409
+
410
+ return plan;
411
+ }
412
+
413
+ // ─── Helpers ────────────────────────────────────────
414
+
415
+ private scanScopesInList(listId: string): string[] {
416
+ const dir = path.join(this.scopesDir, listId);
417
+ if (!fs.existsSync(dir)) return [];
418
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
419
+ }
420
+
421
+ private findClosestList(removedId: string, activeConfig: WorkflowConfig, newConfig: WorkflowConfig): string {
422
+ const removed = activeConfig.lists.find((l) => l.id === removedId);
423
+ const entryId = newConfig.lists.find((l) => l.isEntryPoint)?.id ?? newConfig.lists[0].id;
424
+ if (!removed) return entryId;
425
+
426
+ // 1. Same group as removed list
427
+ if (removed.group) {
428
+ const match = newConfig.lists.find((l) => l.group === removed.group);
429
+ if (match) return match.id;
430
+ }
431
+
432
+ // 2. Closest list by order number
433
+ const sorted = [...newConfig.lists].sort((a, b) =>
434
+ Math.abs(a.order - removed.order) - Math.abs(b.order - removed.order),
435
+ );
436
+ if (sorted.length > 0) return sorted[0].id;
437
+
438
+ // 3. Entry point as last resort
439
+ return entryId;
440
+ }
441
+
442
+ private updateFrontmatterStatus(filePath: string, newStatus: string): void {
443
+ const content = fs.readFileSync(filePath, 'utf-8');
444
+ const updated = content.replace(/^(status:\s*).+$/m, `$1${newStatus}`);
445
+ fs.writeFileSync(filePath, updated);
446
+ }
447
+
448
+ private writeAtomic(targetPath: string, data: WorkflowConfig): void {
449
+ const tmpPath = targetPath + '.tmp';
450
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
451
+ fs.renameSync(tmpPath, targetPath);
452
+ }
453
+
454
+ /** Write config with a _defaultDigest marker so we can detect when the bundled default changes. */
455
+ private writeWithDigest(targetPath: string, config: WorkflowConfig, digest: string): void {
456
+ const withDigest = { _defaultDigest: digest, ...config };
457
+ const tmpPath = targetPath + '.tmp';
458
+ fs.writeFileSync(tmpPath, JSON.stringify(withDigest, null, 2));
459
+ fs.renameSync(tmpPath, targetPath);
460
+ }
461
+ }
@@ -0,0 +1,70 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import type { CcHookEvent, CcHookParsed } from '../../shared/workflow-config.js';
3
+
4
+ const CC_HOOK_EVENTS: CcHookEvent[] = ['SessionStart', 'SessionEnd', 'PreToolUse', 'PostToolUse'];
5
+
6
+ interface SettingsHookEntry {
7
+ type: string;
8
+ command: string;
9
+ statusMessage?: string;
10
+ }
11
+
12
+ interface SettingsMatcherGroup {
13
+ matcher?: string;
14
+ hooks: SettingsHookEntry[];
15
+ }
16
+
17
+ interface SettingsJson {
18
+ hooks?: Record<string, SettingsMatcherGroup[]>;
19
+ }
20
+
21
+ function extractScriptPath(command: string): string {
22
+ // Strip "$CLAUDE_PROJECT_DIR"/ prefix and quotes
23
+ return command
24
+ .replace(/^"?\$CLAUDE_PROJECT_DIR"?\/?/, '')
25
+ .replace(/^["']|["']$/g, '');
26
+ }
27
+
28
+ function deriveId(scriptName: string): string {
29
+ // "init-session.sh" → "init-session"
30
+ // Uses the bare filename so it matches workflow hook IDs when they exist.
31
+ return scriptName.replace(/\.[^.]+$/, '');
32
+ }
33
+
34
+ export function parseCcHooks(settingsPath: string): CcHookParsed[] {
35
+ let raw: string;
36
+ try {
37
+ raw = readFileSync(settingsPath, 'utf-8');
38
+ } catch {
39
+ return [];
40
+ }
41
+
42
+ const settings: SettingsJson = JSON.parse(raw);
43
+ if (!settings.hooks) return [];
44
+
45
+ const results: CcHookParsed[] = [];
46
+
47
+ for (const event of CC_HOOK_EVENTS) {
48
+ const groups = settings.hooks[event];
49
+ if (!Array.isArray(groups)) continue;
50
+
51
+ for (const group of groups) {
52
+ const matcher = group.matcher ?? null;
53
+ for (const entry of group.hooks) {
54
+ if (entry.type !== 'command') continue;
55
+ const scriptPath = extractScriptPath(entry.command);
56
+ const scriptName = scriptPath.split('/').pop() ?? scriptPath;
57
+ results.push({
58
+ id: deriveId(scriptName),
59
+ scriptPath,
60
+ scriptName,
61
+ event,
62
+ matcher,
63
+ statusMessage: entry.statusMessage ?? '',
64
+ });
65
+ }
66
+ }
67
+ }
68
+
69
+ return results;
70
+ }