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,388 @@
|
|
|
1
|
+
import { promisify } from 'util';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import { execFile as execFileCb } from 'child_process';
|
|
4
|
+
import fsSync from 'fs';
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import { getConfig } from '../config.js';
|
|
9
|
+
import { createLogger } from './logger.js';
|
|
10
|
+
const log = createLogger('terminal');
|
|
11
|
+
const execFileAsync = promisify(execFileCb);
|
|
12
|
+
// ─── iTerm2 Dynamic Profiles ────────────────────────────────
|
|
13
|
+
const DYNAMIC_PROFILES_DIR = path.join(os.homedir(), 'Library', 'Application Support', 'iTerm2', 'DynamicProfiles');
|
|
14
|
+
/** Convert a hex color (#rrggbb) to iTerm2's color dictionary format. */
|
|
15
|
+
function hexToItermColor(hex) {
|
|
16
|
+
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
|
17
|
+
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
|
18
|
+
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
|
19
|
+
return { 'Red Component': r, 'Green Component': g, 'Blue Component': b, 'Alpha Component': 1, 'Color Space': 'sRGB' };
|
|
20
|
+
}
|
|
21
|
+
/** Derive a stable, hex-only UUID from a prefix + category string. */
|
|
22
|
+
function deriveGuid(prefix, category) {
|
|
23
|
+
const hash = createHash('md5').update(`${prefix}-${category}`).digest('hex');
|
|
24
|
+
return [
|
|
25
|
+
hash.slice(0, 8),
|
|
26
|
+
hash.slice(8, 12),
|
|
27
|
+
'4' + hash.slice(13, 16),
|
|
28
|
+
'8' + hash.slice(17, 20),
|
|
29
|
+
hash.slice(20, 32),
|
|
30
|
+
].join('-').toUpperCase();
|
|
31
|
+
}
|
|
32
|
+
/** Maps each window category to candidate workflow column IDs (first match wins). */
|
|
33
|
+
const CATEGORY_COLUMN_CANDIDATES = [
|
|
34
|
+
{ category: 'Scoping', columnIds: ['planning', 'backlog'] },
|
|
35
|
+
{ category: 'Planning', columnIds: ['backlog', 'planning'] },
|
|
36
|
+
{ category: 'Implementing', columnIds: ['implementing', 'in-progress'] },
|
|
37
|
+
{ category: 'Reviewing', columnIds: ['review'] },
|
|
38
|
+
{ category: 'Deploying', columnIds: ['production', 'main', 'dev', 'completed', 'done'] },
|
|
39
|
+
];
|
|
40
|
+
const FALLBACK_HEX = '#6B7280'; // neutral gray if no column match
|
|
41
|
+
function profilesFilename(prefix) {
|
|
42
|
+
return `${prefix.toLowerCase()}-dispatch-profiles.json`;
|
|
43
|
+
}
|
|
44
|
+
function buildProfiles(colorMap) {
|
|
45
|
+
const prefix = getConfig().terminal.profilePrefix;
|
|
46
|
+
return {
|
|
47
|
+
Profiles: CATEGORY_COLUMN_CANDIDATES.map(({ category }) => ({
|
|
48
|
+
Name: `${prefix}-${category}`,
|
|
49
|
+
Guid: deriveGuid(prefix, category),
|
|
50
|
+
'Dynamic Profile Parent Name': 'Default',
|
|
51
|
+
'Custom Window Title': category,
|
|
52
|
+
'Use Custom Window Title': true,
|
|
53
|
+
'Allow Title Setting': false,
|
|
54
|
+
'Title Components': 1, // 1 = Session Name (not Job Name)
|
|
55
|
+
'Badge Text': category,
|
|
56
|
+
'Tab Color': hexToItermColor(colorMap.get(category) ?? FALLBACK_HEX),
|
|
57
|
+
'Use Tab Color': true,
|
|
58
|
+
})),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Resolve tab colors from the active workflow's column hex values. */
|
|
62
|
+
function resolveColorMap(engine) {
|
|
63
|
+
const colors = new Map();
|
|
64
|
+
for (const { category, columnIds } of CATEGORY_COLUMN_CANDIDATES) {
|
|
65
|
+
for (const colId of columnIds) {
|
|
66
|
+
const list = engine.getList(colId);
|
|
67
|
+
if (list) {
|
|
68
|
+
colors.set(category, list.hex);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return colors;
|
|
74
|
+
}
|
|
75
|
+
/** Write iTerm2 Dynamic Profiles for each workflow category.
|
|
76
|
+
* Derives tab colors from the active workflow's column definitions.
|
|
77
|
+
* Idempotent — safe to call on every server startup. */
|
|
78
|
+
export async function ensureDynamicProfiles(engine) {
|
|
79
|
+
try {
|
|
80
|
+
await fs.mkdir(DYNAMIC_PROFILES_DIR, { recursive: true });
|
|
81
|
+
const prefix = getConfig().terminal.profilePrefix;
|
|
82
|
+
const filePath = path.join(DYNAMIC_PROFILES_DIR, profilesFilename(prefix));
|
|
83
|
+
const colorMap = resolveColorMap(engine);
|
|
84
|
+
// Write tmp file outside DynamicProfiles/ — iTerm2 watches that dir and reads ALL files
|
|
85
|
+
const tmpPath = path.join(os.tmpdir(), profilesFilename(prefix) + '.tmp');
|
|
86
|
+
await fs.writeFile(tmpPath, JSON.stringify(buildProfiles(colorMap), null, 2));
|
|
87
|
+
await fs.copyFile(tmpPath, filePath);
|
|
88
|
+
await fs.unlink(tmpPath).catch(() => { });
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
log.warn('Failed to write iTerm2 dynamic profiles', { error: err.message });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Maps a WindowCategory to its iTerm2 profile name. */
|
|
95
|
+
function profileNameForCategory(category) {
|
|
96
|
+
return `${getConfig().terminal.profilePrefix}-${category}`;
|
|
97
|
+
}
|
|
98
|
+
/** Ordered array — maps command prefixes to window categories */
|
|
99
|
+
const COMMAND_WINDOW_MAP = [
|
|
100
|
+
['/scope-post-review', 'Reviewing'],
|
|
101
|
+
['/scope-pre-review', 'Planning'],
|
|
102
|
+
['/scope-verify', 'Reviewing'],
|
|
103
|
+
['/scope-create', 'Planning'],
|
|
104
|
+
['/scope-implement', 'Implementing'],
|
|
105
|
+
['/git-commit', 'Deploying'],
|
|
106
|
+
['/git-staging', 'Deploying'],
|
|
107
|
+
['/git-production', 'Deploying'],
|
|
108
|
+
['/git-main', 'Deploying'],
|
|
109
|
+
];
|
|
110
|
+
export function commandToWindowCategory(command) {
|
|
111
|
+
for (const [prefix, category] of COMMAND_WINDOW_MAP) {
|
|
112
|
+
if (command.startsWith(prefix))
|
|
113
|
+
return category;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/** In-memory registry: category → iTerm2 window ID (stable integer). Resets on server restart. */
|
|
118
|
+
const windowRegistry = new Map();
|
|
119
|
+
/**
|
|
120
|
+
* Escape a string for use inside $'...' ANSI-C quoting.
|
|
121
|
+
* Handles backslash, single-quote, and newline characters.
|
|
122
|
+
*/
|
|
123
|
+
export function escapeForAnsiC(text) {
|
|
124
|
+
return text
|
|
125
|
+
.replace(/\\/g, '\\\\')
|
|
126
|
+
.replace(/'/g, "\\'")
|
|
127
|
+
.replace(/\n/g, '\\n')
|
|
128
|
+
.replace(/\r/g, '\\r')
|
|
129
|
+
.replace(/\t/g, '\\t')
|
|
130
|
+
.replace(/\0/g, '\\0')
|
|
131
|
+
.replace(/\x07/g, '\\a')
|
|
132
|
+
.replace(/\x08/g, '\\b')
|
|
133
|
+
.replace(/\x0C/g, '\\f')
|
|
134
|
+
.replace(/\x1B/g, '\\e')
|
|
135
|
+
.replace(/\x0B/g, '\\v');
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Launch a command in a new iTerm2 window.
|
|
139
|
+
*
|
|
140
|
+
* Opens a window with the user's default profile (which sources their shell
|
|
141
|
+
* profile, so PATH includes `claude`), then sends the command via `write text`.
|
|
142
|
+
* This is more reliable than `command "..."` which replaces the shell process
|
|
143
|
+
* and can't interpret builtins like `cd` or operators like `&&`.
|
|
144
|
+
*/
|
|
145
|
+
export async function launchInTerminal(command) {
|
|
146
|
+
const escaped = command.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
147
|
+
await execFileAsync('osascript', [
|
|
148
|
+
'-e', 'tell application "iTerm2"',
|
|
149
|
+
'-e', ' create window with default profile',
|
|
150
|
+
'-e', ' delay 0.5',
|
|
151
|
+
'-e', ' tell current session of current window',
|
|
152
|
+
'-e', ` write text "${escaped}"`,
|
|
153
|
+
'-e', ' end tell',
|
|
154
|
+
'-e', 'end tell',
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Create a new iTerm2 window, run a command, and return the window ID.
|
|
159
|
+
* The window ID is a stable integer that persists for the window's lifetime.
|
|
160
|
+
* Tab name is set via AppleScript session `name` (immune to app title changes).
|
|
161
|
+
* Window title is locked via Dynamic Profile (`Allow Title Setting: false`).
|
|
162
|
+
*/
|
|
163
|
+
async function createWindowWithCommand(command, category, tabName) {
|
|
164
|
+
const escaped = command.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
165
|
+
const tabLabel = (tabName ?? category).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
166
|
+
const profile = profileNameForCategory(category);
|
|
167
|
+
try {
|
|
168
|
+
const { stdout } = await execFileAsync('osascript', [
|
|
169
|
+
'-e', 'tell application "iTerm2"',
|
|
170
|
+
'-e', ` create window with profile "${profile}"`,
|
|
171
|
+
'-e', ' delay 0.5',
|
|
172
|
+
'-e', ' set newWindow to current window',
|
|
173
|
+
'-e', ' tell current session of newWindow',
|
|
174
|
+
'-e', ` set name to "${tabLabel}"`,
|
|
175
|
+
'-e', ` write text "${escaped}"`,
|
|
176
|
+
'-e', ' end tell',
|
|
177
|
+
'-e', ' return id of newWindow',
|
|
178
|
+
'-e', 'end tell',
|
|
179
|
+
]);
|
|
180
|
+
return parseInt(stdout.trim(), 10);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Profile missing — fall back to default profile
|
|
184
|
+
const { stdout } = await execFileAsync('osascript', [
|
|
185
|
+
'-e', 'tell application "iTerm2"',
|
|
186
|
+
'-e', ' create window with default profile',
|
|
187
|
+
'-e', ' delay 0.5',
|
|
188
|
+
'-e', ' set newWindow to current window',
|
|
189
|
+
'-e', ' tell current session of newWindow',
|
|
190
|
+
'-e', ` set name to "${tabLabel}"`,
|
|
191
|
+
'-e', ` write text "${escaped}"`,
|
|
192
|
+
'-e', ' end tell',
|
|
193
|
+
'-e', ' return id of newWindow',
|
|
194
|
+
'-e', 'end tell',
|
|
195
|
+
]);
|
|
196
|
+
return parseInt(stdout.trim(), 10);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Create a new tab in an existing iTerm2 window (identified by ID) and run a command.
|
|
201
|
+
* Returns false if the window no longer exists (user closed it).
|
|
202
|
+
*/
|
|
203
|
+
async function createTabInWindow(windowId, command, category, tabName) {
|
|
204
|
+
const escaped = command.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
205
|
+
const profile = profileNameForCategory(category);
|
|
206
|
+
const nameLines = tabName
|
|
207
|
+
? ['-e', ` set name to "${tabName.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`]
|
|
208
|
+
: [];
|
|
209
|
+
try {
|
|
210
|
+
await execFileAsync('osascript', [
|
|
211
|
+
'-e', 'tell application "iTerm2"',
|
|
212
|
+
'-e', ` set targetWindow to window id ${windowId}`,
|
|
213
|
+
'-e', ' tell targetWindow',
|
|
214
|
+
'-e', ` set newTab to (create tab with profile "${profile}")`,
|
|
215
|
+
'-e', ' tell current session of newTab',
|
|
216
|
+
...nameLines,
|
|
217
|
+
'-e', ` write text "${escaped}"`,
|
|
218
|
+
'-e', ' end tell',
|
|
219
|
+
'-e', ' end tell',
|
|
220
|
+
'-e', 'end tell',
|
|
221
|
+
]);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Route a dispatch to a categorized iTerm2 window.
|
|
230
|
+
* Groups commands by workflow stage (Scoping, Planning, Implementing, Reviewing, Deploying)
|
|
231
|
+
* so multiple dispatches of the same type open as tabs in one window.
|
|
232
|
+
*
|
|
233
|
+
* Falls back to launchInTerminal() for unmapped commands.
|
|
234
|
+
*/
|
|
235
|
+
export async function launchInCategorizedTerminal(command, fullCmd, tabName) {
|
|
236
|
+
const category = commandToWindowCategory(command);
|
|
237
|
+
if (!category) {
|
|
238
|
+
await launchInTerminal(fullCmd);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
// Try reusing an existing window for this category
|
|
242
|
+
const existingId = windowRegistry.get(category);
|
|
243
|
+
if (existingId !== undefined) {
|
|
244
|
+
const added = await createTabInWindow(existingId, fullCmd, category, tabName ?? undefined);
|
|
245
|
+
if (added)
|
|
246
|
+
return;
|
|
247
|
+
// Window was closed — clear stale entry and fall through to create new
|
|
248
|
+
windowRegistry.delete(category);
|
|
249
|
+
}
|
|
250
|
+
// Create a new categorized window
|
|
251
|
+
const newId = await createWindowWithCommand(fullCmd, category, tabName ?? undefined);
|
|
252
|
+
windowRegistry.set(category, newId);
|
|
253
|
+
}
|
|
254
|
+
// ─── Session Naming ──────────────────────────────────────────
|
|
255
|
+
const COMMAND_STEP_MAP = {
|
|
256
|
+
'/scope-implement': 'Implementation',
|
|
257
|
+
'/scope-post-review': 'Post-Review',
|
|
258
|
+
'/scope-pre-review': 'Pre-Review',
|
|
259
|
+
'/scope-verify': 'Verify',
|
|
260
|
+
'/scope-create': 'Creation',
|
|
261
|
+
'/git-commit': 'Commit',
|
|
262
|
+
'/git-dev': 'Merge-Dev',
|
|
263
|
+
'/git-staging': 'PR-Staging',
|
|
264
|
+
'/git-production': 'PR-Production',
|
|
265
|
+
'/git-main': 'Push-Main',
|
|
266
|
+
};
|
|
267
|
+
/** Title-Case slug: "Hook & Event Foundation" → "Hook-Event-Foundation" */
|
|
268
|
+
function slugifySessionName(title, maxLen = 40) {
|
|
269
|
+
return title
|
|
270
|
+
.replace(/[^a-zA-Z0-9\s]/g, '') // strip non-alphanumeric
|
|
271
|
+
.trim()
|
|
272
|
+
.split(/\s+/)
|
|
273
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
274
|
+
.join('-')
|
|
275
|
+
.slice(0, maxLen);
|
|
276
|
+
}
|
|
277
|
+
/** Maps command prefix to step label */
|
|
278
|
+
function commandToStep(command) {
|
|
279
|
+
for (const [prefix, step] of Object.entries(COMMAND_STEP_MAP)) {
|
|
280
|
+
if (command.startsWith(prefix))
|
|
281
|
+
return step;
|
|
282
|
+
}
|
|
283
|
+
return 'Session';
|
|
284
|
+
}
|
|
285
|
+
/** Builds "079-Hook-Event-Foundation-Implementation" from parts */
|
|
286
|
+
export function buildSessionName(parts) {
|
|
287
|
+
const step = commandToStep(parts.command);
|
|
288
|
+
if (parts.scopeId == null) {
|
|
289
|
+
// No scope context — return step-only name for known commands, null for unknown
|
|
290
|
+
return step !== 'Session' ? step : null;
|
|
291
|
+
}
|
|
292
|
+
const paddedId = String(parts.scopeId).padStart(3, '0');
|
|
293
|
+
if (!parts.title)
|
|
294
|
+
return `${paddedId}-${step}`;
|
|
295
|
+
const slug = slugifySessionName(parts.title);
|
|
296
|
+
return `${paddedId}-${slug}-${step}`;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Derive the sessions-index.json path for a project.
|
|
300
|
+
* Claude Code encodes the project path by replacing `/` with `-`.
|
|
301
|
+
* The leading `/` naturally becomes the leading `-` in the directory name.
|
|
302
|
+
*/
|
|
303
|
+
function getSessionsIndexPath(projectRoot) {
|
|
304
|
+
const encoded = projectRoot.replace(/\//g, '-');
|
|
305
|
+
return path.join(os.homedir(), '.claude', 'projects', encoded, 'sessions-index.json');
|
|
306
|
+
}
|
|
307
|
+
/** Rename a session in sessions-index.json by UUID.
|
|
308
|
+
* Updates existing entry or adds a new one if not found. */
|
|
309
|
+
export async function renameSession(projectRoot, sessionId, name) {
|
|
310
|
+
const indexPath = getSessionsIndexPath(projectRoot);
|
|
311
|
+
try {
|
|
312
|
+
const raw = await fs.readFile(indexPath, 'utf-8');
|
|
313
|
+
const index = JSON.parse(raw);
|
|
314
|
+
const existing = index.entries.find((e) => e.sessionId === sessionId);
|
|
315
|
+
if (existing) {
|
|
316
|
+
existing.summary = name;
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
// Entry not in index — add it so the name shows up in the Claude UI
|
|
320
|
+
index.entries.push({ sessionId, fileMtime: Date.now(), summary: name });
|
|
321
|
+
}
|
|
322
|
+
const tmpPath = indexPath + '.tmp';
|
|
323
|
+
await fs.writeFile(tmpPath, JSON.stringify(index, null, 2));
|
|
324
|
+
await fs.rename(tmpPath, indexPath);
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
// Index file not readable — skip silently
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
// ─── PID-based Session Discovery ─────────────────────────────
|
|
331
|
+
/** Directory where init-session.sh stores PID→UUID mapping files */
|
|
332
|
+
function getSessionPidDir(projectRoot) {
|
|
333
|
+
return path.join(projectRoot, '.claude', 'metrics', '.session-ids');
|
|
334
|
+
}
|
|
335
|
+
/** Snapshot current PID files before launching a new session */
|
|
336
|
+
export function snapshotSessionPids(projectRoot) {
|
|
337
|
+
const dir = getSessionPidDir(projectRoot);
|
|
338
|
+
try {
|
|
339
|
+
return new Set(fsSync.readdirSync(dir).map((f) => f.split('-')[0]));
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return new Set();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/** Poll for a new PID file that wasn't in the pre-launch snapshot.
|
|
346
|
+
* Returns the PID and session UUID, or null if no new session appeared. */
|
|
347
|
+
export async function discoverNewSession(projectRoot, beforePidSet) {
|
|
348
|
+
const dir = getSessionPidDir(projectRoot);
|
|
349
|
+
const pollInterval = 500;
|
|
350
|
+
const maxWait = 15_000;
|
|
351
|
+
const deadline = Date.now() + maxWait;
|
|
352
|
+
while (Date.now() < deadline) {
|
|
353
|
+
try {
|
|
354
|
+
const current = fsSync.readdirSync(dir);
|
|
355
|
+
for (const entry of current) {
|
|
356
|
+
const pidStr = entry.split('-')[0];
|
|
357
|
+
if (/^\d+$/.test(pidStr) && !beforePidSet.has(pidStr)) {
|
|
358
|
+
const pid = parseInt(pidStr, 10);
|
|
359
|
+
// Verify the PID is alive (not a stale leftover)
|
|
360
|
+
try {
|
|
361
|
+
process.kill(pid, 0);
|
|
362
|
+
const sessionId = fsSync.readFileSync(path.join(dir, entry), 'utf-8').trim();
|
|
363
|
+
return { pid, sessionId };
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
// Dead PID or unreadable file, skip
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
// Directory not readable — retry
|
|
373
|
+
}
|
|
374
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
/** Check if a session PID is still running.
|
|
379
|
+
* process.kill(pid, 0) sends no signal but checks existence. */
|
|
380
|
+
export function isSessionPidAlive(pid) {
|
|
381
|
+
try {
|
|
382
|
+
process.kill(pid, 0);
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { execFile as execFileCb } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
const execFile = promisify(execFileCb);
|
|
6
|
+
export async function createWorktree(projectRoot, scopeId) {
|
|
7
|
+
const wtPath = path.join(projectRoot, '.worktrees', `scope-${scopeId}`);
|
|
8
|
+
const branch = `feat/scope-${scopeId}`;
|
|
9
|
+
await execFile('git', ['worktree', 'add', wtPath, '-b', branch], { cwd: projectRoot });
|
|
10
|
+
// Ensure scopes/ and .claude/ are in .gitignore (for user projects)
|
|
11
|
+
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
12
|
+
try {
|
|
13
|
+
const content = await fs.readFile(gitignorePath, 'utf-8');
|
|
14
|
+
const lines = content.split('\n');
|
|
15
|
+
const toAdd = [];
|
|
16
|
+
if (!lines.some((l) => l.trim() === 'scopes/'))
|
|
17
|
+
toAdd.push('scopes/');
|
|
18
|
+
if (!lines.some((l) => l.trim() === '.claude/'))
|
|
19
|
+
toAdd.push('.claude/');
|
|
20
|
+
if (toAdd.length > 0) {
|
|
21
|
+
await fs.appendFile(gitignorePath, '\n' + toAdd.join('\n') + '\n');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// .gitignore doesn't exist — create it
|
|
26
|
+
await fs.writeFile(gitignorePath, 'scopes/\n.claude/\n');
|
|
27
|
+
}
|
|
28
|
+
// Remove real scopes/ and .claude/ from worktree (git checkout creates them if tracked)
|
|
29
|
+
const scopesWt = path.join(wtPath, 'scopes');
|
|
30
|
+
const claudeWt = path.join(wtPath, '.claude');
|
|
31
|
+
try {
|
|
32
|
+
await fs.rm(scopesWt, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
catch { /* ok */ }
|
|
35
|
+
try {
|
|
36
|
+
await fs.rm(claudeWt, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
catch { /* ok */ }
|
|
39
|
+
// Create symlinks
|
|
40
|
+
await fs.symlink(path.join(projectRoot, 'scopes'), scopesWt);
|
|
41
|
+
await fs.symlink(path.join(projectRoot, '.claude'), claudeWt);
|
|
42
|
+
return { path: wtPath, branch, scopeId };
|
|
43
|
+
}
|
|
44
|
+
export async function removeWorktree(projectRoot, scopeId) {
|
|
45
|
+
const wtPath = path.join(projectRoot, '.worktrees', `scope-${scopeId}`);
|
|
46
|
+
const branch = `feat/scope-${scopeId}`;
|
|
47
|
+
try {
|
|
48
|
+
await execFile('git', ['worktree', 'remove', wtPath, '--force'], { cwd: projectRoot });
|
|
49
|
+
}
|
|
50
|
+
catch { /* worktree may already be gone */ }
|
|
51
|
+
try {
|
|
52
|
+
await execFile('git', ['branch', '-d', branch], { cwd: projectRoot });
|
|
53
|
+
}
|
|
54
|
+
catch { /* branch may not exist or not be merged */ }
|
|
55
|
+
}
|
|
56
|
+
export async function listWorktrees(projectRoot) {
|
|
57
|
+
const { stdout } = await execFile('git', ['worktree', 'list', '--porcelain'], { cwd: projectRoot });
|
|
58
|
+
const results = [];
|
|
59
|
+
let currentPath = '';
|
|
60
|
+
let currentBranch = '';
|
|
61
|
+
for (const line of stdout.split('\n')) {
|
|
62
|
+
if (line.startsWith('worktree ')) {
|
|
63
|
+
currentPath = line.slice(9);
|
|
64
|
+
}
|
|
65
|
+
else if (line.startsWith('branch ')) {
|
|
66
|
+
currentBranch = line.slice(7);
|
|
67
|
+
}
|
|
68
|
+
else if (line === '' && currentPath) {
|
|
69
|
+
const match = currentPath.match(/scope-(\d+)$/);
|
|
70
|
+
if (match) {
|
|
71
|
+
results.push({ path: currentPath, branch: currentBranch, scopeId: parseInt(match[1]) });
|
|
72
|
+
}
|
|
73
|
+
currentPath = '';
|
|
74
|
+
currentBranch = '';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (currentPath) {
|
|
78
|
+
const match = currentPath.match(/scope-(\d+)$/);
|
|
79
|
+
if (match) {
|
|
80
|
+
results.push({ path: currentPath, branch: currentBranch, scopeId: parseInt(match[1]) });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
export async function cleanupStaleWorktrees(projectRoot) {
|
|
86
|
+
const worktrees = await listWorktrees(projectRoot);
|
|
87
|
+
let cleaned = 0;
|
|
88
|
+
for (const wt of worktrees) {
|
|
89
|
+
try {
|
|
90
|
+
await fs.access(wt.path);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
await removeWorktree(projectRoot, wt.scopeId);
|
|
94
|
+
cleaned++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return cleaned;
|
|
98
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import chokidar from 'chokidar';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { parseEventFile } from '../parsers/event-parser.js';
|
|
5
|
+
import { createLogger } from '../utils/logger.js';
|
|
6
|
+
const log = createLogger('event');
|
|
7
|
+
const ARCHIVE_DIR_NAME = 'processed';
|
|
8
|
+
/**
|
|
9
|
+
* Watch .claude/orbital-events/ for new JSON event files.
|
|
10
|
+
* On startup, processes any existing unprocessed events.
|
|
11
|
+
* After processing, moves files to a /processed subdirectory.
|
|
12
|
+
*/
|
|
13
|
+
export function startEventWatcher(eventsDir, eventService) {
|
|
14
|
+
// Ensure directories exist
|
|
15
|
+
fs.mkdirSync(eventsDir, { recursive: true });
|
|
16
|
+
const archiveDir = path.join(eventsDir, ARCHIVE_DIR_NAME);
|
|
17
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
18
|
+
// Process existing unprocessed events on startup
|
|
19
|
+
processExistingEvents(eventsDir, eventService, archiveDir);
|
|
20
|
+
// Watch for new events
|
|
21
|
+
// NOTE: The events dir is inside .claude/ (dotfile directory).
|
|
22
|
+
// chokidar uses picomatch internally which skips dotfiles by default.
|
|
23
|
+
// We must watch the directory directly and filter in the handler.
|
|
24
|
+
const watcher = chokidar.watch(eventsDir, {
|
|
25
|
+
ignored: [new RegExp(ARCHIVE_DIR_NAME)],
|
|
26
|
+
persistent: true,
|
|
27
|
+
ignoreInitial: true,
|
|
28
|
+
depth: 0,
|
|
29
|
+
});
|
|
30
|
+
watcher.on('error', (err) => log.error('Event watcher error', { error: String(err) }));
|
|
31
|
+
watcher.on('add', (filePath) => {
|
|
32
|
+
if (!filePath.endsWith('.json'))
|
|
33
|
+
return;
|
|
34
|
+
// Small delay to ensure file write is complete
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
processEventFile(filePath, eventService, archiveDir);
|
|
37
|
+
}, 100);
|
|
38
|
+
});
|
|
39
|
+
return watcher;
|
|
40
|
+
}
|
|
41
|
+
function processExistingEvents(eventsDir, eventService, archiveDir) {
|
|
42
|
+
try {
|
|
43
|
+
const files = fs.readdirSync(eventsDir).filter((f) => f.endsWith('.json'));
|
|
44
|
+
if (files.length === 0)
|
|
45
|
+
return;
|
|
46
|
+
log.info('Processing queued events', { count: files.length });
|
|
47
|
+
// Sort by filename (UUID-based, so roughly chronological)
|
|
48
|
+
files.sort();
|
|
49
|
+
for (const file of files) {
|
|
50
|
+
const filePath = path.join(eventsDir, file);
|
|
51
|
+
processEventFile(filePath, eventService, archiveDir);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
const code = err.code;
|
|
56
|
+
if (code !== 'ENOENT') {
|
|
57
|
+
log.error('Event watcher startup error', { error: err.message });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function processEventFile(filePath, eventService, archiveDir) {
|
|
62
|
+
const event = parseEventFile(filePath);
|
|
63
|
+
if (!event)
|
|
64
|
+
return;
|
|
65
|
+
eventService.ingest(event);
|
|
66
|
+
// Move to archive
|
|
67
|
+
const fileName = path.basename(filePath);
|
|
68
|
+
try {
|
|
69
|
+
fs.renameSync(filePath, path.join(archiveDir, fileName));
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
log.warn('Failed to archive event file', { file: filePath, error: err.message });
|
|
73
|
+
// If rename fails (cross-device), just delete the source
|
|
74
|
+
try {
|
|
75
|
+
fs.unlinkSync(filePath);
|
|
76
|
+
}
|
|
77
|
+
catch (unlinkErr) {
|
|
78
|
+
log.warn('Failed to delete event file', { file: filePath, error: unlinkErr.message });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import chokidar from 'chokidar';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { createLogger } from '../utils/logger.js';
|
|
4
|
+
const log = createLogger('scope');
|
|
5
|
+
export function startScopeWatcher(scopesDir, scopeService) {
|
|
6
|
+
const watcher = chokidar.watch(scopesDir, {
|
|
7
|
+
ignored: [/(^|[/\\])\../, /node_modules/, /_template\.md$/],
|
|
8
|
+
persistent: true,
|
|
9
|
+
ignoreInitial: true,
|
|
10
|
+
depth: 2, // scopes/completed/*.md
|
|
11
|
+
});
|
|
12
|
+
watcher
|
|
13
|
+
.on('add', (filePath) => {
|
|
14
|
+
if (!filePath.endsWith('.md') || scopeService.isSuppressed(filePath))
|
|
15
|
+
return;
|
|
16
|
+
log.info('Scope added', { file: path.basename(filePath) });
|
|
17
|
+
scopeService.updateFromFile(filePath);
|
|
18
|
+
})
|
|
19
|
+
.on('change', (filePath) => {
|
|
20
|
+
if (!filePath.endsWith('.md') || scopeService.isSuppressed(filePath))
|
|
21
|
+
return;
|
|
22
|
+
log.debug('Scope changed', { file: path.basename(filePath) });
|
|
23
|
+
scopeService.updateFromFile(filePath);
|
|
24
|
+
})
|
|
25
|
+
.on('unlink', (filePath) => {
|
|
26
|
+
if (!filePath.endsWith('.md') || scopeService.isSuppressed(filePath))
|
|
27
|
+
return;
|
|
28
|
+
log.info('Scope removed', { file: path.basename(filePath) });
|
|
29
|
+
scopeService.removeByFilePath(filePath);
|
|
30
|
+
})
|
|
31
|
+
.on('error', (err) => log.error('Scope watcher error', { error: String(err) }));
|
|
32
|
+
return watcher;
|
|
33
|
+
}
|