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.
- package/LICENSE +21 -0
- package/README.md +396 -0
- package/bin/orbital.js +362 -0
- package/dist/assets/WorkflowVisualizer-BZ21PIIF.js +84 -0
- package/dist/assets/WorkflowVisualizer-BZV40eAE.css +1 -0
- package/dist/assets/charts-D__PA1zp.js +72 -0
- package/dist/assets/index-D1G6i0nS.css +1 -0
- package/dist/assets/index-DpItvKpf.js +419 -0
- package/dist/assets/ui-BvF022GT.js +53 -0
- package/dist/assets/vendor-Dzv9lrRc.js +59 -0
- package/dist/index.html +19 -0
- package/dist/scanner-sweep.png +0 -0
- package/dist/server/server/adapters/index.js +34 -0
- package/dist/server/server/adapters/iterm2-adapter.js +29 -0
- package/dist/server/server/adapters/subprocess-adapter.js +21 -0
- package/dist/server/server/adapters/terminal-adapter.js +1 -0
- package/dist/server/server/config.js +156 -0
- package/dist/server/server/database.js +90 -0
- package/dist/server/server/index.js +372 -0
- package/dist/server/server/init.js +811 -0
- package/dist/server/server/parsers/event-parser.js +64 -0
- package/dist/server/server/parsers/scope-parser.js +188 -0
- package/dist/server/server/routes/config-routes.js +163 -0
- package/dist/server/server/routes/data-routes.js +461 -0
- package/dist/server/server/routes/dispatch-routes.js +215 -0
- package/dist/server/server/routes/git-routes.js +92 -0
- package/dist/server/server/routes/scope-routes.js +215 -0
- package/dist/server/server/routes/sprint-routes.js +116 -0
- package/dist/server/server/routes/version-routes.js +130 -0
- package/dist/server/server/routes/workflow-routes.js +185 -0
- package/dist/server/server/schema.js +90 -0
- package/dist/server/server/services/batch-orchestrator.js +253 -0
- package/dist/server/server/services/claude-session-service.js +352 -0
- package/dist/server/server/services/config-service.js +132 -0
- package/dist/server/server/services/deploy-service.js +51 -0
- package/dist/server/server/services/event-service.js +63 -0
- package/dist/server/server/services/gate-service.js +83 -0
- package/dist/server/server/services/git-service.js +309 -0
- package/dist/server/server/services/github-service.js +145 -0
- package/dist/server/server/services/readiness-service.js +184 -0
- package/dist/server/server/services/scope-cache.js +72 -0
- package/dist/server/server/services/scope-service.js +424 -0
- package/dist/server/server/services/sprint-orchestrator.js +312 -0
- package/dist/server/server/services/sprint-service.js +293 -0
- package/dist/server/server/services/workflow-service.js +397 -0
- package/dist/server/server/utils/cc-hooks-parser.js +49 -0
- package/dist/server/server/utils/dispatch-utils.js +305 -0
- package/dist/server/server/utils/logger.js +86 -0
- package/dist/server/server/utils/terminal-launcher.js +388 -0
- package/dist/server/server/utils/worktree-manager.js +98 -0
- package/dist/server/server/watchers/event-watcher.js +81 -0
- package/dist/server/server/watchers/scope-watcher.js +33 -0
- package/dist/server/shared/api-types.js +5 -0
- package/dist/server/shared/default-workflow.json +616 -0
- package/dist/server/shared/workflow-config.js +44 -0
- package/dist/server/shared/workflow-engine.js +353 -0
- package/index.html +15 -0
- package/package.json +110 -0
- package/postcss.config.js +6 -0
- package/schemas/orbital.config.schema.json +83 -0
- package/scripts/postinstall.js +24 -0
- package/scripts/start.sh +20 -0
- package/server/adapters/index.ts +41 -0
- package/server/adapters/iterm2-adapter.ts +37 -0
- package/server/adapters/subprocess-adapter.ts +25 -0
- package/server/adapters/terminal-adapter.ts +24 -0
- package/server/config.ts +234 -0
- package/server/database.ts +107 -0
- package/server/index.ts +452 -0
- package/server/init.ts +891 -0
- package/server/parsers/event-parser.ts +74 -0
- package/server/parsers/scope-parser.ts +240 -0
- package/server/routes/config-routes.ts +182 -0
- package/server/routes/data-routes.ts +548 -0
- package/server/routes/dispatch-routes.ts +275 -0
- package/server/routes/git-routes.ts +112 -0
- package/server/routes/scope-routes.ts +262 -0
- package/server/routes/sprint-routes.ts +142 -0
- package/server/routes/version-routes.ts +156 -0
- package/server/routes/workflow-routes.ts +198 -0
- package/server/schema.ts +90 -0
- package/server/services/batch-orchestrator.ts +286 -0
- package/server/services/claude-session-service.ts +441 -0
- package/server/services/config-service.ts +151 -0
- package/server/services/deploy-service.ts +98 -0
- package/server/services/event-service.ts +98 -0
- package/server/services/gate-service.ts +126 -0
- package/server/services/git-service.ts +391 -0
- package/server/services/github-service.ts +183 -0
- package/server/services/readiness-service.ts +250 -0
- package/server/services/scope-cache.ts +81 -0
- package/server/services/scope-service.ts +476 -0
- package/server/services/sprint-orchestrator.ts +361 -0
- package/server/services/sprint-service.ts +415 -0
- package/server/services/workflow-service.ts +461 -0
- package/server/utils/cc-hooks-parser.ts +70 -0
- package/server/utils/dispatch-utils.ts +395 -0
- package/server/utils/logger.ts +109 -0
- package/server/utils/terminal-launcher.ts +462 -0
- package/server/utils/worktree-manager.ts +104 -0
- package/server/watchers/event-watcher.ts +100 -0
- package/server/watchers/scope-watcher.ts +38 -0
- package/shared/api-types.ts +20 -0
- package/shared/default-workflow.json +616 -0
- package/shared/workflow-config.ts +170 -0
- package/shared/workflow-engine.ts +427 -0
- package/src/App.tsx +33 -0
- package/src/components/AgentBadge.tsx +40 -0
- package/src/components/BatchPreflightModal.tsx +115 -0
- package/src/components/CardDisplayToggle.tsx +74 -0
- package/src/components/ColumnHeaderActions.tsx +55 -0
- package/src/components/ColumnMenu.tsx +99 -0
- package/src/components/DeployHistory.tsx +141 -0
- package/src/components/DispatchModal.tsx +164 -0
- package/src/components/DispatchPopover.tsx +139 -0
- package/src/components/DragOverlay.tsx +25 -0
- package/src/components/DriftSidebar.tsx +140 -0
- package/src/components/EnvironmentStrip.tsx +88 -0
- package/src/components/ErrorBoundary.tsx +62 -0
- package/src/components/FilterChip.tsx +105 -0
- package/src/components/GateIndicator.tsx +33 -0
- package/src/components/IdeaDetailModal.tsx +190 -0
- package/src/components/IdeaFormDialog.tsx +113 -0
- package/src/components/KanbanColumn.tsx +201 -0
- package/src/components/MarkdownRenderer.tsx +114 -0
- package/src/components/NeonGrid.tsx +128 -0
- package/src/components/PromotionQueue.tsx +89 -0
- package/src/components/ScopeCard.tsx +234 -0
- package/src/components/ScopeDetailModal.tsx +255 -0
- package/src/components/ScopeFilterBar.tsx +152 -0
- package/src/components/SearchInput.tsx +102 -0
- package/src/components/SessionPanel.tsx +335 -0
- package/src/components/SprintContainer.tsx +303 -0
- package/src/components/SprintDependencyDialog.tsx +78 -0
- package/src/components/SprintPreflightModal.tsx +138 -0
- package/src/components/StatusBar.tsx +168 -0
- package/src/components/SwimCell.tsx +67 -0
- package/src/components/SwimLaneRow.tsx +94 -0
- package/src/components/SwimlaneBoardView.tsx +108 -0
- package/src/components/VersionBadge.tsx +139 -0
- package/src/components/ViewModeSelector.tsx +114 -0
- package/src/components/config/AgentChip.tsx +53 -0
- package/src/components/config/AgentCreateDialog.tsx +321 -0
- package/src/components/config/AgentEditor.tsx +175 -0
- package/src/components/config/DirectoryTree.tsx +582 -0
- package/src/components/config/FileEditor.tsx +550 -0
- package/src/components/config/HookChip.tsx +50 -0
- package/src/components/config/StageCard.tsx +198 -0
- package/src/components/config/TransitionZone.tsx +173 -0
- package/src/components/config/UnifiedWorkflowPipeline.tsx +216 -0
- package/src/components/config/WorkflowPipeline.tsx +161 -0
- package/src/components/source-control/BranchList.tsx +93 -0
- package/src/components/source-control/BranchPanel.tsx +105 -0
- package/src/components/source-control/CommitLog.tsx +100 -0
- package/src/components/source-control/CommitRow.tsx +47 -0
- package/src/components/source-control/GitHubPanel.tsx +110 -0
- package/src/components/source-control/GitHubSetupGuide.tsx +52 -0
- package/src/components/source-control/GitOverviewBar.tsx +101 -0
- package/src/components/source-control/PullRequestList.tsx +69 -0
- package/src/components/source-control/WorktreeList.tsx +80 -0
- package/src/components/ui/badge.tsx +41 -0
- package/src/components/ui/button.tsx +55 -0
- package/src/components/ui/card.tsx +78 -0
- package/src/components/ui/dialog.tsx +94 -0
- package/src/components/ui/popover.tsx +33 -0
- package/src/components/ui/scroll-area.tsx +54 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/tabs.tsx +52 -0
- package/src/components/ui/toggle-switch.tsx +35 -0
- package/src/components/ui/tooltip.tsx +27 -0
- package/src/components/workflow/AddEdgeDialog.tsx +217 -0
- package/src/components/workflow/AddListDialog.tsx +201 -0
- package/src/components/workflow/ChecklistEditor.tsx +239 -0
- package/src/components/workflow/CommandPrefixManager.tsx +118 -0
- package/src/components/workflow/ConfigSettingsPanel.tsx +189 -0
- package/src/components/workflow/DirectionSelector.tsx +133 -0
- package/src/components/workflow/DispatchConfigPanel.tsx +180 -0
- package/src/components/workflow/EdgeDetailPanel.tsx +236 -0
- package/src/components/workflow/EdgePropertyEditor.tsx +251 -0
- package/src/components/workflow/EditToolbar.tsx +138 -0
- package/src/components/workflow/HookDetailPanel.tsx +250 -0
- package/src/components/workflow/HookExecutionLog.tsx +24 -0
- package/src/components/workflow/HookSourceModal.tsx +129 -0
- package/src/components/workflow/HooksDashboard.tsx +363 -0
- package/src/components/workflow/ListPropertyEditor.tsx +251 -0
- package/src/components/workflow/MigrationPreviewDialog.tsx +237 -0
- package/src/components/workflow/MovementRulesPanel.tsx +188 -0
- package/src/components/workflow/NodeDetailPanel.tsx +245 -0
- package/src/components/workflow/PresetSelector.tsx +414 -0
- package/src/components/workflow/SkillCommandBuilder.tsx +174 -0
- package/src/components/workflow/WorkflowEdgeComponent.tsx +145 -0
- package/src/components/workflow/WorkflowNode.tsx +147 -0
- package/src/components/workflow/graphLayout.ts +186 -0
- package/src/components/workflow/mergeHooks.ts +85 -0
- package/src/components/workflow/useEditHistory.ts +88 -0
- package/src/components/workflow/useWorkflowEditor.ts +262 -0
- package/src/components/workflow/validateConfig.ts +70 -0
- package/src/hooks/useActiveDispatches.ts +198 -0
- package/src/hooks/useBoardSettings.ts +170 -0
- package/src/hooks/useCardDisplay.ts +57 -0
- package/src/hooks/useCcHooks.ts +24 -0
- package/src/hooks/useConfigTree.ts +51 -0
- package/src/hooks/useEnforcementRules.ts +46 -0
- package/src/hooks/useEvents.ts +59 -0
- package/src/hooks/useFileEditor.ts +165 -0
- package/src/hooks/useGates.ts +57 -0
- package/src/hooks/useIdeaActions.ts +53 -0
- package/src/hooks/useKanbanDnd.ts +410 -0
- package/src/hooks/useOrbitalConfig.ts +54 -0
- package/src/hooks/usePipeline.ts +47 -0
- package/src/hooks/usePipelineData.ts +338 -0
- package/src/hooks/useReconnect.ts +25 -0
- package/src/hooks/useScopeFilters.ts +125 -0
- package/src/hooks/useScopeSessions.ts +44 -0
- package/src/hooks/useScopes.ts +67 -0
- package/src/hooks/useSearch.ts +67 -0
- package/src/hooks/useSettings.tsx +187 -0
- package/src/hooks/useSocket.ts +25 -0
- package/src/hooks/useSourceControl.ts +105 -0
- package/src/hooks/useSprintPreflight.ts +55 -0
- package/src/hooks/useSprints.ts +154 -0
- package/src/hooks/useStatusBarHighlight.ts +18 -0
- package/src/hooks/useSwimlaneBoardSettings.ts +104 -0
- package/src/hooks/useTheme.ts +9 -0
- package/src/hooks/useTransitionReadiness.ts +53 -0
- package/src/hooks/useVersion.ts +155 -0
- package/src/hooks/useViolations.ts +65 -0
- package/src/hooks/useWorkflow.tsx +125 -0
- package/src/hooks/useZoomModifier.ts +19 -0
- package/src/index.css +797 -0
- package/src/layouts/DashboardLayout.tsx +113 -0
- package/src/lib/collisionDetection.ts +20 -0
- package/src/lib/scope-fields.ts +61 -0
- package/src/lib/swimlane.ts +146 -0
- package/src/lib/utils.ts +15 -0
- package/src/main.tsx +19 -0
- package/src/socket.ts +11 -0
- package/src/types/index.ts +497 -0
- package/src/views/AgentFeed.tsx +339 -0
- package/src/views/DeployPipeline.tsx +59 -0
- package/src/views/EnforcementView.tsx +378 -0
- package/src/views/PrimitivesConfig.tsx +500 -0
- package/src/views/QualityGates.tsx +1012 -0
- package/src/views/ScopeBoard.tsx +454 -0
- package/src/views/SessionTimeline.tsx +516 -0
- package/src/views/Settings.tsx +183 -0
- package/src/views/SourceControl.tsx +95 -0
- package/src/views/WorkflowVisualizer.tsx +382 -0
- package/tailwind.config.js +161 -0
- package/templates/agents/AUTO-INVOKE.md +180 -0
- package/templates/agents/CONFLICT-RESOLUTION.md +128 -0
- package/templates/agents/QUICK-REFERENCE.md +122 -0
- package/templates/agents/README.md +188 -0
- package/templates/agents/SKILL-TRIGGERS.md +100 -0
- package/templates/agents/blue-team/frontend-designer.md +424 -0
- package/templates/agents/green-team/architect.md +526 -0
- package/templates/agents/green-team/rules-enforcer.md +131 -0
- package/templates/agents/red-team/attacker-learned.md +24 -0
- package/templates/agents/red-team/attacker.md +486 -0
- package/templates/agents/red-team/chaos.md +548 -0
- package/templates/agents/reference/component-registry.md +82 -0
- package/templates/agents/workflows/full-mode.md +218 -0
- package/templates/agents/workflows/quick-mode.md +118 -0
- package/templates/agents/workflows/security-mode.md +283 -0
- package/templates/anti-patterns/dangerous-shortcuts.md +427 -0
- package/templates/config/agent-triggers.json +92 -0
- package/templates/hooks/agent-team-gate.sh +31 -0
- package/templates/hooks/agent-trigger.sh +97 -0
- package/templates/hooks/block-push.sh +66 -0
- package/templates/hooks/block-workarounds.sh +61 -0
- package/templates/hooks/blocker-check.sh +28 -0
- package/templates/hooks/completion-checklist.sh +28 -0
- package/templates/hooks/decision-capture.sh +15 -0
- package/templates/hooks/dependency-check.sh +27 -0
- package/templates/hooks/end-session.sh +31 -0
- package/templates/hooks/exploration-logger.sh +37 -0
- package/templates/hooks/files-changed-summary.sh +37 -0
- package/templates/hooks/get-session-id.sh +49 -0
- package/templates/hooks/git-commit-guard.sh +34 -0
- package/templates/hooks/init-session.sh +93 -0
- package/templates/hooks/orbital-emit.sh +79 -0
- package/templates/hooks/orbital-report-deploy.sh +78 -0
- package/templates/hooks/orbital-report-gates.sh +40 -0
- package/templates/hooks/orbital-report-violation.sh +36 -0
- package/templates/hooks/orbital-scope-update.sh +53 -0
- package/templates/hooks/phase-verify-reminder.sh +26 -0
- package/templates/hooks/review-gate-check.sh +82 -0
- package/templates/hooks/scope-commit-logger.sh +37 -0
- package/templates/hooks/scope-create-cleanup.sh +36 -0
- package/templates/hooks/scope-create-gate.sh +80 -0
- package/templates/hooks/scope-create-tracker.sh +17 -0
- package/templates/hooks/scope-file-sync.sh +53 -0
- package/templates/hooks/scope-gate.sh +35 -0
- package/templates/hooks/scope-helpers.sh +188 -0
- package/templates/hooks/scope-lifecycle-gate.sh +139 -0
- package/templates/hooks/scope-prepare.sh +244 -0
- package/templates/hooks/scope-transition.sh +172 -0
- package/templates/hooks/session-enforcer.sh +143 -0
- package/templates/hooks/time-tracker.sh +33 -0
- package/templates/lessons-learned.md +15 -0
- package/templates/orbital.config.json +35 -0
- package/templates/presets/development.json +42 -0
- package/templates/presets/gitflow.json +712 -0
- package/templates/presets/minimal.json +23 -0
- package/templates/quick/rules.md +218 -0
- package/templates/scopes/_template.md +255 -0
- package/templates/settings-hooks.json +98 -0
- package/templates/skills/git-commit/SKILL.md +85 -0
- package/templates/skills/git-dev/SKILL.md +99 -0
- package/templates/skills/git-hotfix/SKILL.md +223 -0
- package/templates/skills/git-main/SKILL.md +84 -0
- package/templates/skills/git-production/SKILL.md +165 -0
- package/templates/skills/git-staging/SKILL.md +112 -0
- package/templates/skills/scope-create/SKILL.md +81 -0
- package/templates/skills/scope-fix-review/SKILL.md +168 -0
- package/templates/skills/scope-implement/SKILL.md +110 -0
- package/templates/skills/scope-post-review/SKILL.md +144 -0
- package/templates/skills/scope-pre-review/SKILL.md +211 -0
- package/templates/skills/scope-verify/SKILL.md +201 -0
- package/templates/skills/session-init/SKILL.md +62 -0
- package/templates/skills/session-resume/SKILL.md +201 -0
- package/templates/skills/test-checks/SKILL.md +171 -0
- package/templates/skills/test-code-review/SKILL.md +252 -0
- package/tsconfig.json +25 -0
- 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
|
+
}
|